[도닦기] 1단계: 어느 날, 피싱 메일이 나에게 왔다 (KR)
안녕하세요 Hackyboiz 의 OUYA77입니다.
그동안 팀에 연구글이 뜸했죠 ㅎ.ㅎ…. 내부적으로 새로운 season을 맞이하여 정비를 하고 왔습니다!
2018년에 창단했다는 팀인데 그동안 운영했던 형들이 여러 사정으로 나가시고 지금은 ogu123이 팀장이 되었고 그에 따라 요런저런 것들을 같이 바꾸고 정비하고 왔어요 :)
x 계정도 터져서(?) 소식 전할 창구가 없어서 얼마나 답답했는지 아시나요 ㅜ.ㅜ
팀 내부적으로 프로젝트를 하나 진행했는데 그 이름은 바로
뚜둥

크롬 풀체인 2개 만들기 프로젝트입니다!!
당연히 zero day full chain 은….. 경험도 없고 각자 본업이 있기에 아직은 안건들여 보았는데요
1day chaining 이어서 사실 생각보다 오래 걸리지 않았습니다 ㅎㅎ
2월부터 6월까지 목표로 잡았는데 4월 중순쯤 끝났다는 사실 ☠️
2-3월은 1day case study 하고 재현하고 4월에는 chaining 하면서 서로 exploit 한 내용 공유한 것 같네욤
그래서 겸사겸사 해당 내용을 연구글로 작성해보려고 하는데 조금은 긴 호흡(10부작)으로 한번 가보겠습니다!
그럼 도닦기 프로젝트 연구글 시리즈 많이 관심 부탁드려요 ㅎㅎㅎ
Intro: 도(道)를 아십니까?
배경
프로젝트가 나오게 된 배경부터 간단히 말씀드리면, 작년에 V8 Type Confusion 에 관심이 생겨서 V8 RCE 에 대해서 이런저런 공부를 했는데, “그럼 체이닝은 어떻게 되는거지?” 라는 궁금증이 생기더라구요.
지난 연구글 → https://hackyboiz.github.io/2025/07/01/OUYA77/Chrome_part1/kr/
사실 V8에서 heap sandbox escape 에 대한 내용을 낋여볼라했는데, 이렇게 되었습니다 ㅎㅎ… 혹시 기다리셨던 분들이 계시면 이번 시리즈를 기대해주세요! 여기서도 다뤄보도록 하겠습니다 :)
그리고 제 스스로 생각하기에 exploit은 이제는 AI가 너무 잘해주니까 weaponizing 을 잘할줄 아는 사람이 되어야 하지 않을까 싶기도 해서, 제로데이를 찾기보다는 primitive 를 잘(reliable) 개발 해보자 라는 생각으로 g0ngjae, banda, ji9umi 를 납치해서 풀체인 2개 만들기 프로젝트를 진행해보았습니다.
욕심을 더 내어서 조금 더 멋지게 chaining 을 하고 싶었지만,,,, 이래저래 뭐가 많아서 일단은 프로젝트를 정리하며 글을 써봅니다!!
프로젝트 목표
제로데이 찾는건 out of scope였어서 잘 알려진 1day case study를 하는게 목표였어요.
크롬 체인은 크게 3가지의 step으로 구성이 됩니다.(잘하면 2개로도 가능해요)
- Renderer Compromised
- Chrome Sandbox Escape
- Windows LPE
자세한 내용은 이 시리즈를 통해 천천히 알아보아요 ㅎㅎ
한 개의 chain은 25년 NDC security conference에서 Matteo Malvica님이 발표한 “Chrome Browser Exploitation: from zero to heap sandbox escape” 중 **CVE-2018-17463 (Renderer RCE) 취약점과 Blackhat 2019에서 Zhen Feng, Gengming Liu님이 발표한 “The Most Secure Browser? Pwning Chrome from 2016 to 2019” 중 CVE-2019-5826 (Sandbox Escape) 취약점을 체이닝하였고
윈도우 취약점은 LPE 되는 취약점 중 아무거나 쓰기로 하였는데, b4nda가 제로데이를 찾아서,,, 근데 아쉽게도 패치되어서 제보는 따로 못한 그 취약점을 체이닝 하였습니다.
그리고 다른 하나의 chain은 제가 예전에 봤을 때 입이 떡 벌어졌던,

티오리 연구원분들이 개발하신 VM to LPE in host OS Full chain 중에서 VM 내용이 아닌 위에 3개(CVE-2023-3079, CVE-2023-21674, CVE-2023-29360)에 대해서 chaining 하기로 하였습니다.
이 자리를 빌어서 다시한번 티오리 연구원분들께 좋은 내용의 blog를 작성해주셔서 감사하다고 말씀드립니다!
→ https://theori.io/blog/chaining-n-days-to-compromise-all-part-1-chrome-renderer-rce
제가 궁금했던 내용이 인터넷에 많이 없었어서 시원하게!!! 한번 이야기해보겠습니다 ㅎ.ㅎ
그럼 가보시죠!
피싱메일에서 온 html을 누르면 어떻게 될까?
어느 날 밤, 잘나가는 펀드 매니저인 당신에게 한 통의 메일이 도착했습니다.
“나는 지난 밤 당신이 한 일을 알고 있다.”
의미심장한 제목과 짧은 문장.
지난 밤 특별한 일이라곤 조용히 잠들었던 기억뿐이었지만, 묘한 찝찝함과 호기심이 손끝을 움직였습니다.

당신은 결국 메일에 첨부된 HTML 파일을 열어보았습니다.
모니터에는 잠시 로딩 화면이 스쳐 지나가더니, 이내 아무 내용도 없는 하얀 페이지 하나만 덩그러니 나타났습니다.
‘역시 스팸 메일이었네.’ 생각하며 창을 닫고 오늘 하루를 마무리하기 위해 업무 일지를 정리하고, 남은 메일을 확인한 뒤 늦은 밤 사무실을 나섰습니다.
그날 밤, 당신은 아무 일도 없었다고 생각했습니다.
하지만 다음 날 아침.
출근과 동시에 사무실 분위기가 이상하다는 것을 직감했습니다.
전화는 끊임없이 울려댔고, 직원들의 표정은 새하얗게 질려 있었습니다.
“고객 계좌에서 돈이 전부 빠져나갔습니다!”
“해외로 대량 송금이 발생했어요!”
“접속 기록상 마지막 로그인은… 당신 계정입니다.”
순간 머릿속이 얼어붙었습니다.
당신은 아무것도 하지 않았습니다.
단지, 어젯밤 메일 속 HTML 파일을 한 번 열어봤을 뿐입니다.
크롬에서 HTML 파일을 눌렀을 때 어떤 일이 일어났는지 조금은 긴 호흡으로 살펴봅시다.
Chrome Full-chain Exploit Overview
크롬은 여전히 모바일과 데스크탑에서 사랑받는 웹 브라우저 프로그램입니다.
](image%203.png)
ref. https://martech.zone/browser-market-share/
사실 오늘날 대부분의 사용자가 크롬 브라우저를 사용하고 있기 때문에, 이 크롬에서의 취약점은 impact가 크다고 말할 수 있습니다. 또한 크롬은 브라우저 특성상 대규모의 메모리 공간과 복잡한 객체 구조를 다루고 있으며, 렌더링·JavaScript 엔진·네트워크 스택 등 다양한 구성 요소가 긴밀하게 연결되어 있습니다. 따라서 공격자가 크롬의 취약점을 성공적으로 악용할 경우, 프로세스 메모리 접근이나 코드 실행과 같은 강력한 exploit primitive를 확보할 수 있습니다. 더 나아가 크롬은 파일 시스템, 그래픽 처리, 네트워크 통신 등 운영체제의 여러 기능과 상호작용하기 때문에, 브라우저를 장악한다는 것은 단순한 애플리케이션 수준의 문제를 넘어 시스템 전반에 영향을 미칠 수 있다는 것을 의미합니다.
그렇기에 많은 사람들에게 사랑받는 만큼 해커들에게도 굉장히 사랑받는 소프트웨어입니다.
다른 윈도우에서의 프로그램들은 소프트웨어를 exploit하면 RCE를 획득할 수 있습니다. 하지만 크롬은 유용한 만큼 보안에도 신경써서 RCE를 획득 하기 위해서는 조금 더 복잡한 과정을 지나야 합니다. Full-chain Exploit 흐름은 보통 다음과 같은 흐름으로 이루어집니다.

이번 프로젝트에서는 각각의 단계에 대해서 step-by-step으로 살펴보려고합니다.
Chain 1: CVE-2018-17463 + CVE-2019-5826 + Windows LPE 1-day
다음은 저희가 개발한 Chrome Full-chain Exploit 1에 대한 데모영상입니다.
단순히 웹 브라우저에서 html을 켰을 뿐인데 시스템 권한의 명령어를 실행할 수 있게 되었습니다.

이 체인은 V8 RCE(CVE-2018-17463) + Sandbox Escape(CVE-2019-5826) 과 저희가 개발한 Windows LPE 취약점을 체이닝한 것으로 html 하나만으로 Windows의 System 권한을 획득하는 아주 멋찐 체인입니다(자화자찬).
인터넷에 공개된 내용이 있으니까 간단하게만 말씀을 드리고 상세한 내용은 시리즈를 통해 긴 호흡으로 만나보시죠! 이번 체인은 Chrome 69.0.3497.100 on VMware(Windows 11)에서 개발하였습니다.
CVE-2018-17463(V8 Type Confusion - Renderer RCE)
지난 연구글 Recap
처음에 들어오는 input은 당연히 렌더러에서의 html(+css, js) 정보겠죠!
그 정보가 처리될 때 Type Confusion이 발생하였고 이 Overlapping 되는 bug를 이용해 Relative R/W 프리미티브(addrOf, fakeObj primitive)를 만들 수 있습니다. 아 참고로 이때는 Heap Sandbox가 없었다는 점 알아주세요 🫡.
백킹 스토어 포인터를 덮어쓸 수 있으므로, 두 개의 ArrayBuffer를 사용하여 AAR/W 프리미티브를 구축할 수 있었습니다.
이제 확보한 AAR/W primitive를 기반으로 WASM 인스턴스 객체의 주소와 RWX jump table pointer를 leak할 수 있습니다. 다만 기존 addrOf primitive는 프로퍼티 영역을 overlapping하여 덮어써 객체를 손상시킬 수 있었기에, 보다 안정적인 방식의 primitive가 필요했습니다.
이를 위해 ArrayBuffer 객체에 out-of-line property를 추가하고, 해당 프로퍼티가 객체를 참조하도록 구성하였습니다. 이후 property store 내부 오프셋을 읽어 객체 포인터를 leak하는 방식을 사용하여 객체 필드를 직접 손상시키지 않고도 안정적으로 프리미티브를 얻을 수 있었습니다.
그 후 WebAssembly 인스턴스를 생성하고 RWX 권한의 점프테이블을 만들어줍니다. 이 점프테이블의 주소를 leak하고 그 점프테이블에 AAW 프리미티브를 이용하여 쉘코드를 덮어씁니다. 그리고 해당 함수를 실행하면!!

계산기가 떨어집니다 ㅎㅎ (Sandbox 옵션을 OFF 한 후 킨 크롬입니다. 원래는 Renderer RCE를 얻어도 크롬 프로세스 샌드박스 때문에 계산기 프로세스가 안떠요 🫠)
Primitive에 대해서 사실 인터넷에 잘 나와있는 정보가 없었어서,,,(적어도 제가 찾았을 때는)
제가 exploit 공부하면서 느낀 Primitive에 대해 적어보겠습니다.Primitive 란?
- 반복 가능 / 제어 가능 / 다음 단계 입력으로 사용 가능한
- 하나의 Exploit API?!
계산기를 띄웠다는건 RCE가 가능하다는 것!! RCE 가 가능하면 무엇을 해야할까요!!!

바로 크롬을 옥죄고 있는 감옥으로부터 해방시켜줘야겠죠 ㅠㅠ

그럼 다음 크롬 샌드박스 이스케이프 취약점으로 넘어가보시죠!
CVE-2019-5826(UAF in IndexedDB - Sandbox Escape)
크롬 샌드박스에 대한 자세한 내용도 시리즈에서 다룰 예정이어서 기대해주세요 ㅎㅎ
참고로 렌더러에서의 힙 샌드박스와 크롬 샌드박스는 다른 이야기에요. 간단하게 말해보면 힙 샌드박스는 렌더러 프로세스의 메모리에 대한 내용이고, 크롬 샌드박스는 렌더러 프로세스 같은 프로세스들의 권한을 억제한거랍니다. 자세한 내용은 추후에 다룰게요 :)
렌더러에서 RCE를 획득했으면 PEB Walking(gs:[0x60] 참조를 통한 kernel32.dll 및 VirtualAlloc 탐색)이라는 기법을 통해 ASLR 우회가 가능합니다. 따라서 다음 Exploit에서는 ASLR은 우회했다고 가정하고 작성해보겠습니다.
CVE-2019-5826은 Black hat USA 2019에서 발표된 취약점으로 크롬에 IndexedDB라는 기능이 있는데, 여기서 UAF가 발생하여 샌드박스를 탈출할 수 있는 취약점이었습니다.
간단히 맛만 봐볼까요? 냠냠 (Step 1이니까 상세 PoC 는 다루지 않겠습니담)

Chrome에서 IndexedDB는 대부분 브라우저 프로세스 내에서 구현됩니다. IndexedDB는 브라우저 내장된 NoSQL 데이터베이스로 렌더러가 API를 통해 요청하면, 브라우저 프로세스가 처리하는 구조입니다. 이로 인해 웹페이지가 대용량 구조화 데이터를 클라이언트에 저장할 수 있게 해주는데요.
여기서 비동기 요청 큐와 강제 종료 로직이 충돌하는 버그가 생겼습니다. 다시 말해, 이미 해제된 DB 객체를 사용할 수 있게 되는 구조적 lifetime bug라고 할 수 있어요.
Step 1. UAF
IndexedDB는 DB로 오는 요청에 대해 pending_requests라는 큐에 담아둡니다. 그리고 객체를 할당하고 reference count 변수로 객체의 free 시점을 정해요.
그런데 다음과 같이 요청을 보내면, Dangling pointer가 생깁니다.
- Open(“db1”, 1);
- Open(“db1”, 2);
- DeleteDatabase(“db1”, force_close=True);
- AbortTransactionsForDatabase();
DeleteDatabase(force_close=true)는 ForceClose() 과정에서 현재 연결된 모든 IndexedDB 연결(connections_)을 순회하며 하나씩 종료합니다. 문제는 마지막 연결이 닫히는 시점에 발생합니다.
마지막 connection이 close되는 순간, 대기 중이던 Open(v2) 요청이 처리되면서 version upgrade transaction이 시작됩니다. 이 과정에서 새로운 upgrade connection이 다시 connections_ 배열에 삽입됩니다. 하지만 기존 close 루프는 이미 종료 직전 상태였기 때문에, 새로 삽입된 connection은 정상적으로 정리되지 않습니다.
그 결과 Close() 내부의 다음 조건이 더 이상 만족되지 않게 됩니다.
connections_.empty() && !active_request_
이 조건이 거짓이 되면서 factory_->ReleaseDatabase() 호출이 스킵되고, 결국 database_map_ 내부에는 해당 IndexedDBDatabase 객체가 계속 등록된 상태로 남게 됩니다.
이후 AbortTransactionsForDatabase()가 version change transaction을 abort시키면, IndexedDBDatabase의 refcount가 0이 되면서 객체가 해제됩니다. 그러나 앞서 `database_map` 정리가 누락되었기 때문에, map 내부에는 이미 해제된 객체를 가리키는 dangling pointer가 남게 됩니다. 이후 해당 포인터가 재사용되면서 최종적으로 Use-After-Free가 발생합니다.
Step 2. Spray
얻어진 Dangling pointer를 이용하기 위해 Heap Spraying 을 진행합니다. createObjectStore라는 함수가 있는데, 이는 key_path string 을 저장하는 함수입니다. 하지만 Renderer RCE 로 Blink 엔진을 우회해서 문자열이 아니더라도 해당 함수를 통해 바이트를 보낼 수 있게 되어요. 그래서 문자열 0x170바이트를 저희가 원하는 구조체로 만들어 스프레이를 해줍니다. 저는 많이도 아니고 한 50번정도하니까 slot 에 올라갔어요 🤤
Step 3. Heap Addresss Leak
스프레이가 성공해서 해제됐던 0x170 슬롯이 가짜 IndexedDBDatabase로 덮였습니다.
객체의 구조체를 보면 다음과 같습니다.
| 오프셋 | 필드 | 값 | leak에서의 역할 |
|---|---|---|---|
| +0x000 | vptr |
0 |
vtable 호출 방지 |
| +0x008 | ref_count_ |
0xFFFFFFFF00000030 |
leak 단계 동안 객체 유지 |
| +0x010 | backing_store_ |
0 |
크래시 방지 |
| +0x018 | metadata_.name (string16) |
0 (SSO 빈 문자열) |
Commit 직렬화 시 복사 크래시 방지 |
| +0x050 | metadata_.object_stores (map) |
0 (빈 맵) |
순회 크래시 방지 |
| +0x060 | identifier_ |
0 (0x70) |
문자열 복사 크래시 방지 |
| +0x0D0 | factory_ |
0 |
null |
| +0x128 | connections_ |
0 (0x20) |
빈 상태 |
| +0x148 | active_request_ |
0x4142434445464748 |
non-null 마커 → AppendRequest가 ProcessRequestQueue 스킵(Perform 안 돎=크래시 회피) + readback 시 “이 슬롯에 스프레이 성공” 판별 |
| +0x150 | pending_requests_.buffer_ |
0 |
**push_back이 자동 malloc |
→ 여기 힙 주소 기록 = leak 타깃** |
| +0x158/160/168 | capacity_/begin_/end_ | 0 | 빈 deque → 첫 pushback에서 확장 유발 |
| +0x170 | `processing_pending_requests|0` | — |
객체의 +0x150에 자리한 pending_requests_(base::circular_deque)가 이번 step의 키포인트 입니다. 스프레이를 위한 페이로드를 만들 때 이 필드를 전부 0으로 비워뒀습니다. 동시에 바로 위 +0x148의 active_request_에는 일부러 0x4142434445464748이라는 null이 아닌 마커 값을 박아뒀죠. 이 두 가지 세팅이 leak의 전부입니다.
이제 댕글링 포인터를 통해 Open("db1")을 보냅니다. 요청은 브라우저 IDB 스레드에서 IndexedDBDatabase::AppendRequest()까지 흘러가 pending_requests_.push_back()을 호출하게 되는데, 여기서 두 가지 일이 동시에 벌어집니다.
먼저,
active_request_가 null이 아니기 때문에AppendRequest는 요청을 큐에 넣자마자if (active_request_) return;으로 빠져나갑니다. 즉ProcessRequestQueue → OpenRequest::Perform이 실행되지 않아 가짜 객체를 실제로 들여다보다가 크래시 나는 일이 없습니다. 요청은 그냥 큐에 쌓이기만 하죠. 그런데 그 “큐에 쌓는” 동작이 핵심입니다.circular_deque가 텅 비어 있다(buffer_=NULL, capacity_=0)가 첫 원소를 받으면, 내부적으로 할당을 받으면서 새 버퍼를malloc합니다. 그리고 그 새로 할당된 힙 주소가 곧바로buffer_, 즉 가짜 객체의+0x150에 적혀버립니다.Open을 한 번만 보내도 주소는 박히지만, 여기서는 총 15번을 보냅니다. push_back이 거듭되며 deque가 점점 커지고(3→4→…→16) 그때마다 버퍼가 재할당되는데,+0x150은 항상 가장 마지막(가장 큰) 버퍼를 가리킵니다. 15번이면 내부 용량 16짜리 버퍼, 즉16 × 8 = 0x80바이트짜리 청크가 잡히는데, 이 0x80이라는 크기가 뒤따라올 Step 4에서 한 번 더 쓰일 예정이라 일부러 맞춘 값입니다.남은 건
+0x150에 박힌 이 주소를 다시 읽어오는 일입니다. 우리가 스프레이한 key_path 문자열의 내부 버퍼가 곧 이 가짜 객체 메모리 자체니까, key_path를 도로 읽으면+0x150의 8바이트도 같이 딸려 나와야 정상입니다. 그런데 여기엔 함정이 하나 있습니다.순진하게
Commit을 하고 그SuccessDatabase콜백에서 key_path를 읽으면 주소가 사라집니다.Commit은 메타데이터를 LevelDB로 직렬화하고, 콜백은 그 LevelDB에서 메타데이터를 다시 재구성하기 때문이죠. 즉 거기서 보이는 key_path는 디스크에서 새로 만들어진 복사본이라, push_back이 메모리에 써넣은 힙 포인터가 들어 있을 리 없습니다.그래서 두 단계로 나눕니다. 먼저
Commit으로 version-change 트랜잭션을 닫아 스프레이 버퍼들을 살려둔 다음, 별도로Open("db_reuse", v=0)을 한 번 더 호출합니다. 이 Open은 디스크가 아니라 메모리에 살아 있는(live) 메타데이터를 그대로 돌려주는데, 이때의 key_pathstring16객체들은 여전히 원래 힙 버퍼(댕글링 포인터를 재 점유한 그 곳!)를 가리키고 있습니다. 따라서 이 콜백에서 읽은 key_path의+0x150에는 push_back이 써둔 힙 주소가 고스란히 남아 있습니다.콜백 핸들러는 돌아온 object store들의 keypath를 하나씩 훑으면서, 먼저
+0x148이 우리 마커0x4142434445464748인지 확인합니다(맞으면 “이 슬롯에 스프레이가 제대로 올라갔다”것을 뜻합니다). 그다음+0x150의 8바이트를 읽어 상위 32비트가 0이 아니고 8바이트 정렬된 그럴듯한 힙 포인터인지 검증한 뒤, 이 값을 `leaked_heap_addr로 확정합니다. 마커가0x4141…`이거나 0이면 스프레이가 그 슬롯을 못 잡은 것이라 새 origin에서 재시도합니다.이렇게 얻은
leaked_heap_addr_는 IDB 스레드 힙 위에 놓인 그 0x80바이트 deque 버퍼의 주소입니다. 이제 우리는 무작정 추측하던 힙을 구체적인 앵커 하나로 고정한 셈이고, 이후 Step에서 힙 관련 주소 전부를 이 값 기준으로 계산하게 됩니다.
간단히 요약하면 이렇습니다.
- Open(“db1“, v=0) × 15 → dangling ptr 통해 push_back
- circular_deque 자동 할당 → 0x80 Heap buffer 주소가 spray+0x150에 기록
- Commit db_reuse + re-Open → SuccessDatabase 콜백
- key_path 문자열 readback → +0x150에서 heap addr 추출
- leakedheap_addr 획득!
Step 4. AAF(Arbitrary Address Free) Primitive
위에서 보셨듯이 pending_requests는 circular deque 자료형이 쓰이는데요. 원형이다 보니까 expand할 때 뒤에있는 공간을 더 차지하는게 아니라, 기존에 있던 공간을 free하고 새로 expand하는 만큼의 공간을 할당합니다. 따라서 active_request_ 를 non-null로 세팅 후, 다음과 같이 pending_requests를 채우면
pending_requests
- buffer_ = leaked_heap_addr_ <-해제할 주소 (FREE 대상)
- capacity_=1, begin_=end_=0 (empty, expand 대기)
buffer_ 변수에 저장되는 주소를 임의로 해제할 수 있습니다.
따라서 pending_requests를 자유롭게 컨트롤할 수 있으면 원하는 임의 주소를 Free 할 수 있는 프리미티브를 얻을 수 있습니다. 이제 이 AAF를 실제로 터뜨려 보겠습니다.
앞서 UAF로 이용했던 슬롯을 다시 지웁니다. DeleteDatabase("db_reuse", force_close=true)를 호출하면 그 origin의 key_path 문자열들이 해제되고, 결과적으로 0x170 슬롯이 다시 free 상태로 돌아옵니다. Step 3에서는 이 공간을 Leak을 위해서 썼다면, 지금은 AAF 프리미티브를 얻기 위해 사용할 예정입니다.
이번엔 Free된 공간을 AAF 페이로드로 채웁니다. Open("db_aaf")로 새 업그레이드 트랜잭션을 열고 CreateObjectStore를 50번 날리면, 그중 하나의 string16 버퍼가 방금 비워진 0x170 슬롯을 재점유합니다. 이 페이로드의 pending_requests는 일부러 “비어 있지만 확장이 필요한” 상태로 조작돼 있습니다.
그리고 Open("db1")을 댕글링 포인터로 보내면 AppendRequest → push_back이 이 조작된 deque 위에서 실행됩니다. deque 입장에선 크기는 0인데 용량도 0이라, ExpandCapacityIfNecessary가 발동해 새 버퍼를 malloc하고 기존 buffer_를 free해 버립니다. 그런데 그 기존 buffer_가 바로 leaked_heap_addr_니까, 결국 free(leaked_heap_addr_), 즉 우리가 고른 임의 주소가 해제됩니다.
마지막으로 pushback이 끝난 뒤 `active_request를0x101(non-null)로 박아둔 덕분에AppendRequest는 곧장 빠져나가ProcessRequestQueue를 건너뜁니다. 이게 필요한 이유는 가짜 요청을 실제로Perform`하지 않고 리턴하여 익셉션을 우회합니다. 임의 주소 하나를 해제했지만 프로세스는 멀쩡히 살아 있는, 깔끔한 AAF 프리미티브가 완성됩니다.
Step 5-6. 가짜 Factory 할당 → Browser RCE
AAF로 임의 주소를 해제할 수 있는 순간, 사실 게임은 거의 끝난 겁니다.
이제 코드 실행 흐름을 탈취하기 위해서 Vtable 을 Overwrite 해보겠습니다. Chromium 브라우저 프로세스 안에 Factory란 친구가 있습니다. 이 친구는 origin별 IndexedDB 데이터베이스를 통째로 관리하는 객체(IndexedDBFactory)인데, 렌더러가 Open·DeleteDatabase등의 API를 부를 때마다 Factory 객체 안에 있는 가상 함수가 호출됩니다.
따라서 Factory 객체를 AAF를 이용하여 임의로 할당하고 vtable을 overwrite 한 후 ROP를 실행시켜주면!!!
렌더러 RCE에서 시작한 한 줄의 댕글링 포인터가, 결국 샌드박스 바깥 브라우저 프로세스의 권한으로 WinExec 함수를 실행하게 됩니다. 냐미!

해당 익스를 돌리면 한 40초~1분 정도 걸리는데, 이게 스프레이도 하고 또 이것저것 하다보니까 바로 되지 않는 건 아쉽더라구요. 개인적으로 해당 익스 reliable이랑 최적화 등등 해보고 싶긴한데,,,, ㅎㅎ…… 나중에 해야겠어요(다이어트는 내일부터와 비슷하다는 말은 ㄴㄴ)
사실 ROP 코드로 stack pivot 하고 winexec 함수로 calc.exe 실행시켰는데 이 공간이 작거든요. 그래서 Windows LPE랑 체이닝 할 때는 Windows LPE 페이로드를 a.exe로 놓고 이 실행파일을 실행시키는 걸로 체이닝했습니다. 더 좋은 방법을 알고 계시는 분은 hackyboizteam@gmail.com으로 알려주시면 감사드리겠습니다 🙇♂️
Windows LPE Payload(made by banda)
안녕하세요 banda입니다. 잠깐 왔습니다.
앞서 V8 exploit과 Sandbox Escape는 object layout, Mojo IPC, browser object lifetime처럼 Chrome 내부 상태에 의존하는 편이었는데, Kernel LPE는 Medium IL에서 실행 가능한 상태가 되면 그 이후 kernel stage로 분리할 수 있었기 때문에 chaining level 중 상대적으로 조금 더 독립적이었던 것 같습니다. (그만큼 앞의 팀원분들은 대단하셨습니다…🐐)
따라서 저는 이번에 chaining에 필요한 kernel LPE 유형을 생각해보다보니 컨셉을 잡고(?) 이전에 hackyboiz에서 다뤄보지 않았던 MDL Abuse 관련된 버그패턴으로 인한 Windows LPE payload를 완성해보았는데요. 제가 찾았던 현재는 패치되어 안전한 취약점의 버그 패턴 예시와 exploit primitive를 간단하게 정리하고, 추후 도닦기 시리즈 마지막 즈음에 나올 Kernel LPE 단계 글에서 더 자세하게 소개드리도록 하겠습니다.😁
MDL 관련 취약점 맛보기: MDL Unregister Double-Free
MDL를 간단히 정의해보면 커널이 특정 virtual address buffer가 실제 어떤 physical page들로 구성되어 있는지 표현하기 위해 사용되는 커널 구조체인데요.
제가 보았던 case도 이 MDL 관련 취약점에서 자주 문제가 되었던 MDL의 lifetime 관리 문제였습니다. MDL 주기는 보통의 경우 alloc, page lock, mapping, unmapping, free 과정을 거치는데, driver가 이 객체를 slot table이나 queue에 저장해두고 여러 요청에서 공유하면 동일한 MDL pointer를 여러 요청이 동시에 참조하는 lifetime race 문제가 발생할 수 있었습니다.
[unregister flow]
input.va
↓
find matching slot
↓
slot = &a1[6 * idx]
↓
slot state clear
↓
if slot[16]:
user_va = slot[15]
mdl = a1[2 * (3 * idx + 9)]
MmUnmapLockedPages(user_va, mdl)
IoFreeMdl(mdl)
else:
mdl = slot[17]
if mdl:
IoFreeMdl(mdl)
특히 register/unregister 구조에서는 같은 MDL pointer를 여러 thread가 동시에 참조할 가능성이 생기는데요. 제가 분석했던 패턴의 경우에도 register 과정에서 저장된 mapped VA와 MDL pointer를 unregister 루틴에서 다시 찾아 해제하는 구조였고, 이때 slot lookup → slot clear → MmUnmapLockedPages() → IoFreeMdl() 흐름이 lock 없이 수행되면서 double-free race로 이어질 수 있었던 취약점이었습니다.
Windows 22H2 kernel driver exploit primitive
MDL bug pattern을 찾은 후에는 Windows 22h2 환경을 기준으로 다양한 방법이 있겠지만, 체이닝에 연계되었던 CVE-2023-29360의 경우 Medium IL 프로세스가 kernel driver와 통신해 current process의 token object를 조작하는 방식으로 primitive를 완성시킬 수 있게 되는데요.
- 먼저
NtQuerySystemInformation(SystemHandleInformation)을 이용해 현재 process의TOKENkernel address를 leak하고,TOKEN + privilege offset을 target address로 잡은 뒤 - MDL 취약점을 통해 target kernel address에 대해 user-mode mapped VA를 얻어오면, 해당 VA를 쓰는 것만으로 kernel의 token privilege bitmap을 수정할 수 있고
- 이를 통해
SeDebugPrivilege를 enable 가능한 상태로 만든 뒤,OpenProcess(winlogon.exe)에 성공한 뒤 SYSTEM 권한의cmd.exe를 실행해
체이닝을 위한 단계 중 마지막 단계인 Windows LPE Payload를 완성할 수 있게 되었습니다!
따라서 위와 같이 취약점을 엮어 체인을 만들면, 피싱메일에서 어떤 웹페이지를 눌렀을 때 윈도우 system 권한을 탈취당할 수 있습니다.
⛰️도닦기 시리즈 소개
네 지금 보시는 글이 바로 저희 도닦기 연구글 시리즈 1편이었는데요. 지금 많은 글들을 준비중에 있사오니 구독·좋아요·알람설정(은 없지만 많은 관심부탁드려요 😇)
2단계에서는 Type Confusion 에 대한 간단한 요약을 곁들이며 AAR/W가 Caged AAR/W가 된 내용에 대해서 다뤄보려고 해요. 2020년 이후부터는 렌더러 영역에 Heap Sandbox가 들어와서 익스하기가 아주…. 어려워졌답니다 🫠(Offensive 관점에서는 좋은건지 안좋은건지 모르겠네욤 ㅎ.ㅎ)
그 이후에는 티오리 블로그에서 다뤘던 3가지 체인에 대해서 이야기하며 중간중간 기본 개념에 대해서도 다뤄보려고 합니닷
이 글을 다 읽으면 과연 크롬 풀체인이 무엇인지 알 수 있을까요?
그렇게 알 수 있도록 열심히 글써보도록 하겠습니다!! 같이 긴 호흡으로 달려보아요 🏃♂️🏃♀️💨
다음화 예고

Reference.
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.