Пожалуйста, обратите внимание, что пользователь заблокирован
ДИСКЛЕЙМЕР!
Это черновая версия путеводителя, которая будет изменяться и дополняться с учетом замечаний и пожеланий. Путеводитель рассчитан на непоследовательное чтение, поэтому каждый может добавить собственную точку маршрута по какой-либо актуальной проблеме, связанной с драйверами, ядром ОС Windows или добавить что-то к уже существующим. Возможно кому-то это упростит жизнь.
(c) varwar
Это черновая версия путеводителя, которая будет изменяться и дополняться с учетом замечаний и пожеланий. Путеводитель рассчитан на непоследовательное чтение, поэтому каждый может добавить собственную точку маршрута по какой-либо актуальной проблеме, связанной с драйверами, ядром ОС Windows или добавить что-то к уже существующим. Возможно кому-то это упростит жизнь.
(c) varwar
Содержание
Введение

Чего в этом путеводителе вы не увидете?

Точки маршрута
1. Отправная точка

2. DriverEntry

3. Отлавливаем загрузку драйвера

4. Как узнать назначение аргумента в неизвестной функции?

5. Наиболее популярные возвращаемые коды NTSTATUS

6. Определяем неизвестныые флаги

7. Магия

8. В каком виде могут быть представлены ioctl?

9. Как фильтруются недоверенные указатели?

10. Как определить код, обрабатывающий пользовательские данные?

11. Код для x86 в 64-битных драйверах

12. Для чего нужны встроенные библиотеки типов и как их применять?

13. Ошибки декомпилятора

14. Как документировать базу?

15. Как отлаживать драйверы без .pdb?

Нерешенные проблемы

Заключение

Полезные ссылки

Введение
Почему путеводитель? Путь и водитель на английском звучит как "path" и "driver". Эта игра слов семантически подходит для нижеизложенного текста.
Мы будем посещать как самые людные и популярные места, так и закоулки драйвера по нашему путеводителю, исследуя различные точки маршрута, которые я, как ваш гид наметил. Надеюсь, что со временем их будет появляться больше. Цель этого путеводителя заключается в том, чтобы вы тратили меньше времени на анализ, научились замечать паттерны и освоили нечто большее, чем
F5. Где-то наоборот, вам нужно будет потратить чуточку больше времени на документирование, комментирование ваших мыслей и находок. Также это попытка собрать в одном месте методы и решения распространенных проблем. Так для кого же предназначен этот путеводитель? Для любого, кому интересно устройство ОС Windows - разработчикам читов, багхантерам, исследователям, разработчикам эксплойтов, разработчикам защитных решений, школьникам, студентам и всем неравнодушным.В этом путеводителе я коснусь только архитектуры драйверов WDM.
Как и любому туристу в пути нам потребуются инструменты, владение которыми будет определять степень нашего комфорта. Без дизассемблера/декомпилятора и ядерного отладчика исследование драйверов невозможно. Это необходимый минимум с которым мы пройдем путь от начала до конца путеводителя. Я рекомендую использовать IDA Pro 7.7+ и WinDbg, хотя предоставленная информация по большей части не будет привязана к инструментам как таковым. И все же почему IDA? В IDA 7.5 появилась функция создания директорий, которую я считаю недооцененной. Директории позволяют структурировать драйвер по его функциональности и держать его образ в голове более осмысленным. Также другому человеку будет проще сориентироваться в вашей базе, если она документирована надлежащим образом, особенно если этот другой человек вы сами через какое-то время. Кроме этого для IDA предустановлены богатые библиотеки типов на основе Windows Driver Kit, которые также будут активно использоваться для улучшения вывода декомпилятора. В конце концов для IDA существует большое количество полезных расширений, без некоторых я уже не представляю своей жизни. То же можно сказать и про WinDbg.
Чего в этом путеводителе вы не увидете?
Здесь вас не научат писать эксплойты, драйвера и искать уязвимости. По моему скромному мнению написание ядерных эксплойтов сейчас зачастую требует индивидуального подхода к проблеме, про написание драйверов уже написано много литературы, есть документация, WDK (Windows Driver Kit) и репозиторий с большим количеством примеров драйверов на любой вкус и цвет. Тем не менее то, что будет описано в путеводителе вы будете использовать с большой долей вероятности повседневно независимо от вашей узкой специализации на протяжении лет.
Точки маршрута
Описанные приемы представлены точками, они независимы и представляют из себя решение той или иной проблемы с которой столкнется каждый, кто будет исследовать драйвера или ядро.
1. Отправная точка
Здесь пойдет речь о подготовке. Было бы безрассудно начинать исследовать драйвера ничего про них не зная. Про написание драйверов существует множество материалов, я бы выделил книги "Programming the Microsoft Windows Driver Model" и "Windows Kernel Programming". Эти две книги должны стать настольными* на случай, если что-то в коде будет непонятно, а информации на MSDN покажется недостаточно.
Под настольными я подразумеваю электронные версиии книг т.к. искать по "Ctrl + F" проще, чем листать макулатуру
Исходные коды Windows XP SP1 (x64)/Windows Server 2003 (x64) являются настоящим подарком с точки зрения приватной информации об ОС. Данные исходники прекрасно документированы, структурированы и по сей день актуальны. Хочу отметить, что я не призываю использовать исключительно эти источники. Если вам удалось решить проблему без них - хорошо, но если вы застряли и не знаете где еще искать - вспомните про них. Поверьте, вы удивитесь, когда после реверса какого-либо модуля потом обнаружите его в исходных кодах Windows. Директории, которые представляют наибольший интерес при знакомстве с устройством Windows:
- \XPSP1\NT\base\ntos - ядро операционной системы, разделенное на подсистемы
- \XPSP1\NT\net - сетевые драйвера
Допустим, вы нашли какой-то интересующий драйвер и открыли его в IDA. Даже если вы начали с исследования уязвимой функции после разбора патча, вероятнее всего придется начать с функции
DriverEntry по той причине, что в DriverEntry описывается ioctl-обработчик для взаимодействия с юзеромодными компонентами. Этот тот случай, когда драйвер не является системной библиотекой*.В системной библиотеке функция DriverEntry просто возвращает 0 (STATUS_SUCCESS), а в таблице экспорта есть функция DllInitialize.
C:
NTSTATUS __stdcall DriverEntry(_DRIVER_OBJECT *DriverObject, PUNICODE_STRING RegistryPath)
{
return 0; // STATUS_SUCCESS
}
3. Отлавливаем загрузку драйвера
Случается так, что необходимо поставить брейкопоинт на какой-нибудь функции в драйвере для патчинга в рантайме. Например, когда вы исследуете состояние памяти, пытаетесь определить типы недокументированных функций и их аргументов или же пытаетесь стригеррить баг в коде. Не каждый драйвер находится загруженным в памяти, поэтому мы не сможем просто поставить брейкпоинт. К счастью, WinDbg поддерживает исключения. Буквально одной короткой командой мы устанавливаем исключение на загрузку нужного нам драйвера.
Код:
sxe ld <driver.sys>
Просмотреть все виды исключений, а также установленные исключения мы можем командой
sx.Обратите внимание на строчку
ld.2: kd> sx
ct - Create thread - ignore
et - Exit thread - ignore
cpr - Create process - output
epr - Exit process - break
ld - Load module - break
(only break for ntfs.sys)
ud - Unload module - ignore
ser - System error - ignore
ibp - Initial breakpoint - break
iml - Initial module load - break
out - Debuggee output - output
av - Access violation - break - not handled
asrt - Assertion failure - break - not handled
aph - Application hang - break - not handled
bpe - Break instruction exception - break
bpec - Break instruction exception continue - handled
eh - C++ EH exception - second-chance break - not handled
clr - CLR exception - second-chance break - not handled
clrn - CLR notification exception - second-chance break - handled
cce - Control-Break exception - break
cc - Control-Break exception continue - handled
cce - Control-C exception - break
cc - Control-C exception continue - handled
dm - Data misaligned - break - not handled
dbce - Debugger command exception - ignore - handled
gp - Guard page violation - break - not handled
ii - Illegal instruction - second-chance break - not handled
ip - In-page I/O error - break - not handled
dz - Integer divide-by-zero - break - not handled
iov - Integer overflow - break - not handled
ch - Invalid handle - break
hc - Invalid handle continue - not handled
lsq - Invalid lock sequence - break - not handled
isc - Invalid system call - break - not handled
3c - Port disconnected - second-chance break - not handled
svh - Service hang - break - not handled
sse - Single step exception - break
ssec - Single step exception continue - handled
sbo - Security check failure or stack buffer overrun - break - not handled
sov - Stack overflow - break - not handled
vs - Verifier stop - break - not handled
vcpp - Visual C++ exception - ignore - handled
wkd - Wake debugger - break - not handled
rto - Windows Runtime Originate Error - second-chance break - not handled
rtt - Windows Runtime Transform Error - second-chance break - not handled
wob - WOW64 breakpoint - break - handled
wos - WOW64 single step exception - break - handled
* - Other exception - second-chance break - not handled
ct - Create thread - ignore
et - Exit thread - ignore
cpr - Create process - output
epr - Exit process - break
ld - Load module - break
(only break for ntfs.sys)
ud - Unload module - ignore
ser - System error - ignore
ibp - Initial breakpoint - break
iml - Initial module load - break
out - Debuggee output - output
av - Access violation - break - not handled
asrt - Assertion failure - break - not handled
aph - Application hang - break - not handled
bpe - Break instruction exception - break
bpec - Break instruction exception continue - handled
eh - C++ EH exception - second-chance break - not handled
clr - CLR exception - second-chance break - not handled
clrn - CLR notification exception - second-chance break - handled
cce - Control-Break exception - break
cc - Control-Break exception continue - handled
cce - Control-C exception - break
cc - Control-C exception continue - handled
dm - Data misaligned - break - not handled
dbce - Debugger command exception - ignore - handled
gp - Guard page violation - break - not handled
ii - Illegal instruction - second-chance break - not handled
ip - In-page I/O error - break - not handled
dz - Integer divide-by-zero - break - not handled
iov - Integer overflow - break - not handled
ch - Invalid handle - break
hc - Invalid handle continue - not handled
lsq - Invalid lock sequence - break - not handled
isc - Invalid system call - break - not handled
3c - Port disconnected - second-chance break - not handled
svh - Service hang - break - not handled
sse - Single step exception - break
ssec - Single step exception continue - handled
sbo - Security check failure or stack buffer overrun - break - not handled
sov - Stack overflow - break - not handled
vs - Verifier stop - break - not handled
vcpp - Visual C++ exception - ignore - handled
wkd - Wake debugger - break - not handled
rto - Windows Runtime Originate Error - second-chance break - not handled
rtt - Windows Runtime Transform Error - second-chance break - not handled
wob - WOW64 breakpoint - break - handled
wos - WOW64 single step exception - break - handled
* - Other exception - second-chance break - not handled
Убрать исключение можно командой
sxd ld <driver.sys>.После перезагрузки виртуальной машины мы с большой долей вероятности отловим загрузку драйвера без стороннего вмешательства в работу системы. Стек вызовов должен выглядеть следующим образом:
Код:
2: kd> k4
# Child-SP RetAddr Call Site
00 ffffe282`6a47d038 fffff805`39f39a55 nt!DebugService2+0x5
01 ffffe282`6a47d040 fffff805`39f3982f nt!DbgLoadImageSymbols+0x45
02 ffffe282`6a47d090 fffff805`3a38ac5b nt!DbgLoadImageSymbolsUnicode+0x33
03 ffffe282`6a47d0d0 fffff805`3a388857 nt!MiDriverLoadSucceeded+0x19b
На примере ниже перечислены загруженные модули в момент срабатывания брейкпоинта. Для примера я установил исключение для драйвера
mskssrv.sys. Пока что символы для него не загружены и мы не можем установить брейкпоинт на какую-либо функцию в этом драйвере. Мы должны явно подгрузить символьную информацию.
Код:
2: kd> lm
start end module name
ffffe6cf`02120000 ffffe6cf`021ca000 win32k # (pdb symbols) d:\symbols\win32k.pdb\17469094424A3FB2929E97EC00A99F531\win32k.pdb
ffffe6cf`02200000 ffffe6cf`025aa000 win32kfull (deferred)
...
fffff805`55230000 fffff805`55242000 MSKSSRV (deferred)
Чтобы загрузить символы для нужного драйвера выполняем команду
ld.
Код:
2: kd> ld mskssrv
Symbols loaded for MSKSSRV
Теперь мы можем поставить брейкпоинт на любую функцию драйвера, например на
DriverEntry и продолжить исполнение.
Код:
2: kd> bp mskssrv!DriverEntry
2: kd> g
Breakpoint 0 hit
MSKSSRV!DriverEntry:
fffff805`5523e078 4053 push rbx
Не всегда удается отловить загрузку драйвера таким образом, порой нужно совершить в системе какое-либо действие для триггера драйвера - прибавить громкость, открыть файловый менеджер, разлогиниться и т.д. Иногда можно догадаться какое действие требуется совершить исходя из функциональности драйвера. Данный метод будет работать исключительно для тех драйверов, у которых есть символьная информация.
4. Как узнать назначение аргумента в неизвестной функции?
Данную проблему можно отнести к основным проблемам при разборе любого приложения. Зачастую я пользуюсь следующими методами:
- Анализ функций в обратном порядке
- Анализ функции в контексте
- Проверка гипотезы при помощи отладчика
- Здравый смысл
проецироваться на недокументированную функцию. Давайте разберем на живом примере*.
Для этого примера я взял функцию не из драйвера, но из ядра Windows, т.к. он в себе сочетает разные подходы к решению одной проблемы и заставляет немного напрячься.
C:
unsigned __int64 __fastcall AlpcpProbeAndCaptureMessageHeader(unsigned __int64 a1, __int64 a2, int a3)
{
unsigned __int64 result; // rax
__m128i v4; // xmm1
unsigned __int64 v5; // xmm0_8
__int16 v6; // ax
result = 0x7FFFFFFF0000i64;
if ( (a3 & 0xC0000000) == 0x80000000 )
{
if ( a1 < 0x7FFFFFFF0000i64 ) // MmUserProbeAddress check
result = a1;
v4 = *(__m128i *)result;
v5 = *(_QWORD *)(result + 0x10);
v6 = _mm_cvtsi128_si32(*(__m128i *)result);
*(_WORD *)a2 = v6;
*(_WORD *)(a2 + 2) = v6 + 0x28;
*(_DWORD *)(a2 + 4) = v4.m128i_i32[1];
*(_QWORD *)(a2 + 8) = (unsigned int)_mm_cvtsi128_si32(_mm_srli_si128(v4, 8));
*(_QWORD *)(a2 + 0x10) = HIDWORD(_mm_srli_si128(v4, 8).m128i_u64[0]);
result = (unsigned int)v5;
*(_QWORD *)(a2 + 0x20) = HIDWORD(v5);
*(_DWORD *)(a2 + 0x20) = HIDWORD(v5);
*(_DWORD *)(a2 + 0x18) = v5;
}
else
{
if ( a1 < 0x7FFFFFFF0000i64 )
result = a1;
*(_OWORD *)a2 = *(_OWORD *)result;
*(_OWORD *)(a2 + 0x10) = *(_OWORD *)(result + 0x10);
*(_QWORD *)(a2 + 0x20) = *(_QWORD *)(result + 0x20);
}
return result;
}
Пока вывод декомпилятора выглядет крайне неприглядно, но с первых строчек можно сразу сказать, что первый аргумент - это указатель пользовательского режима, возвращаемое значение соответственно тоже указатель.
0x7FFFFFFF0000 - это значение глобальной переменной MmUserProbeAddress, с которой происходит сравнение.Подробнее будет расказано в теме валидации указателей и определении пользовательских данных. Назначение второго аргумента пока неясно, выглядит как структура, которая инициализируется. Третий аргумент похож на флаг в котором проверяется наличие/отсутствие 31 бита.
C:
if ( (a3 & 0xC0000000) == 0x80000000 )
Пока это лишь гипотеза.
Python:
In [6]: bin(0x80000000)
Out[6]: '0b10000000000000000000000000000000'
In [7]: bin(0xC0000000)
Out[7]: '0b11000000000000000000000000000000'
Но есть куда более ценная подсказка. В драйверах и ядре Windows функции, которые содержат слова "Capture" и "Probe" просто фильтруют входящие пользовательские данные и как правило содержат в аргументах два буфера одинакового типа - входящий и исходящий, который по сути является локальной копией первого. Теперь начнем подниматься вверх, откроем
xrefs, нажав X на имени функции.
Скажу сразу, что функции
NtAlpcOpenSenderThread и NtAlpcOpenSenderProcess довольно быстро приведут нас к ответу, но я все же покажу на более сложном примере, потому что с таким вы тоже будете сталкиваться. Для этого примера выберем AlpcpProcessConnectionRequest.
C:
if ( a3 )
{
AlpcpProbeForWriteMessageHeader(a3, a2);
AlpcpProbeAndCaptureMessageHeader(a3, &v38, a2);
}
Здесь мы видим, что два аргумента, которые передаются в интересующую нас функцию являются также аргументами функции
AlpcpProcessConnectionRequest. Второй аргумент &v38 - это адрес стековой переменной. Для наглядности соотнесем имеющуюся информацию.
Повторяем процедуру.
Мы можем в процессе анализировать код функций и строить новые гипотезы, используя второй метод. Пока мы продолжаем подниматься наверх в надежде, что это приведет нас куда-нибудь. И действительно, на четвертой функции мы попадаем в системный вызов
NtAlpcConnectPort, который содержит интересующие нас аргументы.
На этом наша змейка не заканчивается. Проблема в том, что в
.pdb нет прототипов функций для подсистемы ALPC. В отличие от других системных вызовов этот недокументирован... т.е. официально.Если мы загуглим в поиске этот сискол, то найдем прототипы функций и типы для
ALPC в системной утилите Process Hacker. Назначаем все типы вручную для NtAlpcConnectPort и продолжаем змейку в обратную сторону.
C:
_PORT_MESSAGE *__fastcall AlpcpProbeAndCaptureMessageHeader(
_PORT_MESSAGE *InPortMessage,
_PORT_MESSAGE *OutPortMessage,
int Flags)
{
_PORT_MESSAGE *CapturedMessageHeader; // rax
__m128i v4; // xmm1
unsigned __int64 UniqueThread; // xmm0_8
__int16 DataLength; // ax
CapturedMessageHeader = (_PORT_MESSAGE *)0x7FFFFFFF0000i64;
if ( (Flags & 0xC0000000) == 0x80000000 )
{
if ( (unsigned __int64)InPortMessage < 0x7FFFFFFF0000i64 )
CapturedMessageHeader = InPortMessage;
v4 = *(__m128i *)&CapturedMessageHeader->u1.s1.DataLength;
UniqueThread = (unsigned __int64)CapturedMessageHeader->ClientId.UniqueThread;
DataLength = _mm_cvtsi128_si32(*(__m128i *)&CapturedMessageHeader->u1.s1.DataLength);
OutPortMessage->u1.s1.DataLength = DataLength;
OutPortMessage->u1.s1.TotalLength = DataLength + 0x28;
OutPortMessage->u2.ZeroInit = v4.m128i_u32[1];
OutPortMessage->ClientId.UniqueProcess = (void *)(unsigned int)_mm_cvtsi128_si32(_mm_srli_si128(v4, 8));
OutPortMessage->ClientId.UniqueThread = (void *)HIDWORD(_mm_srli_si128(v4, 8).m128i_u64[0]);
CapturedMessageHeader = (_PORT_MESSAGE *)(unsigned int)UniqueThread;
OutPortMessage->ClientViewSize = HIDWORD(UniqueThread);
OutPortMessage->CallbackId = HIDWORD(UniqueThread);
OutPortMessage->MessageId = UniqueThread;
}
else
{
if ( (unsigned __int64)InPortMessage < 0x7FFFFFFF0000i64 )
CapturedMessageHeader = InPortMessage;
*OutPortMessage = *CapturedMessageHeader;
}
return CapturedMessageHeader;
}
Теперь наш код выглядит чуточку информативнее.
При анализе функции в контексте мы пытаемся идентифицировать аргументы, опираясь на контекст функции. Что это значит? Возьмем, к примеру, функцию
NtAlpcOpenSenderProcess. Ядро и драйвера Windows часто взаимодействуют со встроенными объектами, идентифицируя которые мы можем улучшить читаемость кода как в дизассемблере, так и в декомпиляторе. Порой код сильно преображается, стоит лишь указать верный тип данных объекта. Ниже представлен паттерн.
C:
// ...Skip
Object = 0i64;
v11 = ObReferenceObjectByHandle(a2, 0x20000u, AlpcPortObjectType, PreviousMode, &Object, 0i64);
// ...Skip
v11 = AlpcpLookupMessage(Object, DWORD2(v27), v28, v12, &BugCheckParameter2);
// ...Skip
IDA определила тип аргумента
Object как PVOID, но так ли это? И да, и нет. Это действительно указатель, но указатель на объект, который описывается структурой, какая это именно структура зависит от того какой тип объекта мы запрашиваем у функции ObReferenceObjectByHandle. Третий аргумент в ObReferenceObjectByHandle говорит нам о типе объекта - это порт ALPC. Какая структура соответствует этому объекту? Мы можем попытаться догадаться. Открываем в IDA загруженные типы из .pdb нажатием Shift+F1, начинаем вводить в строку поиска "ALPC". К слову, все системные структуры в Windows начинаются с нижнего подчеркивания.
Кажется то, что нужно. Теперь мы можем явно указать тип в прототипе функции
AlpcpLookupMessage как _ALPC_PORT* вместо PVOID. Весь список объектов можно посмотреть утилитой WinObjEx.
Таким образом мы просто находим перекрестные ссылки аргумента внутри исследуемой функции и пытаемся использовать имеющуюся информацию вызываемых функций по отношению к нашей. Найти все вхождения какой-либо переменной в IDA можно таким же нажатием
X.Самое очевидное в таких случаях - найти в перекрестных ссылках какую-нибудь документированную функцию с известными типами.
IDA зачастую такую информацию о типах обрабатывает рекурсивно, но не всегда.
Все, что мы делали выше - мы делали в статике. Если есть сомнения, то мы пользуемся третьим методом - отладчиком. Ставим брейкпоинт на нужной функции и проверяем аргументы встроенными командами
!object или !handle, потому что нередко первым аргументом ставится указатель на объект или описатель.Четвертвый метод является базовым.
5. Наиболее популярные возвращаемые коды NTSTATUS
В ядре и драйверах Windows, вероятно большинство функций возвращают NTSTATUS. В декомпиляторе "магические числа", обозначающие статус ошибки вы можете идентифицировать как 32-битное число, начинающееся с
0xCXXXXXXX. Вы можете встретить следующие паттерны.
C:
// Паттерн 1
v13 = 0xC000009A;
return v13;
// Паттерн 2
return 0xC0000022
В таблицу я вынес наиболее распространенные коды NTSTATUS.
Код:
|Код |Hex |
|---------------------------------|-------------------|
|STATUS_INVALID_PARAMETER | 0xC000000D |
|STATUS_INVALID_DEVICE_REQUEST | 0xC0000010 |
|STATUS_SUCCESS | 0x0 |
|STATUS_BUFFER_TOO_SMALL | 0xC0000023 |
|STATUS_UNSUCCESSFULL | 0xC0000001 |
|STATUS_ACCESS_DENIDED | 0xC0000022 |
|STATUS_INVALID_PARAMETER_1 | 0xC00000EF |
|STATUS_INVALID_PARAMETER_2 | 0xC00000F0 |
|STATUS_INVALID_PARAMETER_3 | 0xC00000F1 |
|STATUS_INVALID_PARAMETER_4 | 0xC00000F2 |
|STATUS_INVALID_PARAMETER_5 | 0xC00000F3 |
|STATUS_INTEGER_OVERFLOW | 0xC0000095 |
6. Определяем неизвестныые флаги
В одном из роликов Off By One Security пытались разобрать технологию Windows Exploit Guard. В какой-то момент ведущий и его гость стали реверсить функцию ядра
SeTokenGetNoChildProcessRestricted.Просто нажав
F5, сложно сказать какие данные здесь представлены.
C:
bool __fastcall SeTokenGetNoChildProcessRestricted(__int64 a1, bool *a2, bool *a3, bool *a4)
{
struct _KTHREAD *CurrentThread; // rax
int v9; // edi
bool result; // al
CurrentThread = KeGetCurrentThread();
--CurrentThread->KernelApcDisable;
ExAcquireResourceSharedLite(*(a1 + 0x30), 1u);
v9 = *(a1 + 0xC8);
ExReleaseResourceLite(*(a1 + 0x30));
KeLeaveCriticalRegionThread(KeGetCurrentThread());
*a2 = (v9 & 0x80000) != 0;
result = (v9 & 0x100000) != 0;
*a3 = result;
*a4 = (v9 & 0x200000) != 0;
return result;
}
Начнем улучшать вывод декомпилятора. Во-первых, эта функция документирована и имеет прототип:
C:
void SeTokenGetNoChildProcessRestricted(
[in] PACCESS_TOKEN Token,
[out] PBOOLEAN Enforced,
[out] PBOOLEAN UnlessSecure,
[out] PBOOLEAN AuditOnly
);
Тут стоит отметить, что
PACCESS_TOKEN - это в действительности указатель на структуру _TOKEN. Указав IDA этот тип мы сразу получаем более привлекательную картину.
C:
void __stdcall SeTokenGetNoChildProcessRestricted(
_TOKEN *Token,
PBOOLEAN Enforced,
PBOOLEAN UnlessSecure,
PBOOLEAN AuditOnly)
{
struct _KTHREAD *CurrentThread; // rax
unsigned int TokenFlags; // edi
CurrentThread = KeGetCurrentThread();
--CurrentThread->KernelApcDisable;
ExAcquireResourceSharedLite(Token->TokenLock, 1u);
TokenFlags = Token->TokenFlags;
ExReleaseResourceLite(Token->TokenLock);
KeLeaveCriticalRegionThread(KeGetCurrentThread());
*Enforced = (TokenFlags & 0x80000) != 0;
*UnlessSecure = (TokenFlags & 0x100000) != 0;
*AuditOnly = (TokenFlags & 0x200000) != 0;
}
Какие же биты проверяются в
TokenFlags и каковы значения флагов 0x80000, 0x100000, 0x200000? Есть несколько способов узнать. Герои ролика обнаружили значения флагов в WDK, применив немного здравого смысла. А если в IDA есть встроенные типы WDK, то...?Верно, мы можем попробовать их угадать и применить прям в декомпиляторе. Жмем
M на 0x80000, далее жмем New и начинаем вводить, например, TOKEN.Флаги в Windows по моим наблюдениям ВСЕГДА пишутся в верхнем регистре. Это маленькое знание поможет отсеивать ненужные значения, которые по какой-либо причине совпадают с искомым.
На скриншоте ниже мы можем видеть всего два значения, которые совпадают. Если мы вернемся и посмотрим на название нашей функции, то ответ станет очевиден.
Применим ту же тактику по отношении к двум оставшимся флагам. По-моему, теперь код выглядит намного чище и информативней. Человек, читающий нашу базу будет благодарен.
C:
void __stdcall SeTokenGetNoChildProcessRestricted(
_TOKEN *Token,
PBOOLEAN Enforced,
PBOOLEAN UnlessSecure,
PBOOLEAN AuditOnly)
{
struct _KTHREAD *CurrentThread; // rax
unsigned int TokenFlags; // edi
CurrentThread = KeGetCurrentThread();
--CurrentThread->KernelApcDisable;
ExAcquireResourceSharedLite(Token->TokenLock, TRUE);
TokenFlags = Token->TokenFlags;
ExReleaseResourceLite(Token->TokenLock);
KeLeaveCriticalRegionThread(KeGetCurrentThread());
*Enforced = (TokenFlags & TOKEN_NO_CHILD_PROCESS) != 0;
*UnlessSecure = (TokenFlags & TOKEN_NO_CHILD_PROCESS_UNLESS_SECURE) != 0;
*AuditOnly = (TokenFlags & TOKEN_AUDIT_NO_CHILD_PROCESS) != 0;
}
Итак, повышая информативность кода за счет применения типов мы упрощаем себе задачу в понимании кода, его отладке или других задач, требующих погружения в отдельно взятые участки приложения. Почему этими методами не пользуется тренер SANS - это вопрос, который для меня остался без ответа. Мы потратили не больше двух минут.
8. В каком виде могут быть представлены ioctl?
Чтобы найти код, обрабатывающий ioctl, нам необходимо сначала найти где происходит инициализация IRP-запросов. Часто встречающийся паттерн, когда одна функция обрабатывает различные типы IRP. Как правило код инициализации всех IRP находится в
DriverEntry.
C:
/* Инициализация в DriverEntry
DriverObject->MajorFunction[IRP_MJ_CREATE] = CngDispatch;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = CngDispatch;
DriverObject->MajorFunction[IRP_MJ_READ] = CngDispatch;
DriverObject->MajorFunction[IRP_MJ_WRITE] = CngDispatch;
DriverObject->MajorFunction[IRP_MJ_QUERY_INFORMATION] = CngDispatch;
DriverObject->MajorFunction[IRP_MJ_QUERY_VOLUME_INFORMATION] = CngDispatch;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = CngDispatch;
*/
// Реализация
NTSTATUS __fastcall CngDispatch(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
CurrentStackLocation = Irp->Tail.Overlay.CurrentStackLocation;
switch ( CurrentStackLocation->MajorFunction )
{
case IRP_MJ_CREATE:
// Skip...
case IRP_MJ_CLOSE:
// Skip...
case IRP_MJ_READ:
// Skip...
case IRP_MJ_WRITE:
// Skip...
case IRP_MJ_QUERY_INFORMATION:
// Skip...
case IRP_MJ_QUERY_VOLUME_INFORMATION:
// SKip...
case IRP_MJ_DEVICE_CONTROL:
ioctl_code = CurrentStackLocation->Parameters.DeviceIoControl.IoControlCode;
// Обработчик ioctl
status = CngDeviceControl(
SystemBuffer,
CurrentStackLocation->Parameters.DeviceIoControl.InputBufferLength,
SystemBuffer,
&OutputBufferLength,
ioctl_code,
Irp->RequestorMode);
// Skip...
}
}
Указатель на обработчик ioctl находится по индексу
IRP_MJ_DEVICE_CONTROL в массиве MajorFunctions и содержит в своем названии как правило слова "Device", "Control", "Io".Пример таких функций в различных драйверах:
C:
BOOLEAN __fastcall AfdFastIoDeviceControl
NTSTATUS __fastcall SrvDispatchIoControl
NTSTATUS __stdcall KsSynchronousIoControlDevice
NTSTATUS __fastcall NpCommonFileSystemControl
// И т.д.
C:
NTSTATUS __stdcall DriverEntry(_DRIVER_OBJECT *DriverObject, PUNICODE_STRING RegistryPath)
{
// Skip...
DriverObject->MajorFunction[IRP_MJ_PNP_POWER] = KsDefaultDispatchPnp;
DriverObject->MajorFunction[IRP_MJ_POWER] = KsDefaultDispatchPower;
DriverObject->MajorFunction[IRP_MJ_SYSTEM_CONTROL] = KsDefaultForwardIrp;
DriverObject->MajorFunction[IRP_MJ_CLEANUP] = (PDRIVER_DISPATCH)FsDispatchCleanUp;
DriverObject->DriverExtension->AddDevice = (PDRIVER_ADD_DEVICE)PnpAddDevice;
DriverObject->DriverUnload = (PDRIVER_UNLOAD)FsDriverUnload;
KsSetMajorFunctionHandler(DriverObject, IRP_MJ_CREATE);
KsSetMajorFunctionHandler(DriverObject, IRP_MJ_CLOSE);
KsSetMajorFunctionHandler(DriverObject, IRP_MJ_DEVICE_CONTROL);
return STATUS_SUCCESS;
}
В данном случае
KsSetMajorFunctionHandler, являясь импортируемой функцией драйвера ks.sys, отвечает за обработку ioctl.Перейдем непосредственно к обработчикам ioctl и выделим несколько паттернов.
1. switch/case
Один из самых распространенных, идентифицируемых невооруженным глазом и удобочитаемых паттернов.
C:
switch ( ioctl_code )
{
case 0x390044u:
v17 = &off_FFFFF8004DC0B320;
return CryptIoctlReturnKernelmodePointer(sys_buf, a4, Mode, v17);
case 0x390048u:
v17 = &off_FFFFF8004DC0B368;
return CryptIoctlReturnKernelmodePointer(sys_buf, a4, Mode, v17);
case 0x390064u:
v17 = &unk_FFFFF8004DC0CEB0;
return CryptIoctlReturnKernelmodePointer(sys_buf, a4, Mode, v17);
case 0x390073u:
// Skip ...
Или аналогичный паттерн непосредственно в ядре Windows. Да, там тоже присутствуют ioctl-обработчики, например функция
PiCMHandleIoctl.
C:
switch ( ioctl_code )
{
case 0x470863u:
return PiCMOpenClassKey(InputBuffer, InputBufferSize, OutputBuffer, OutputBufferSize, PreviousMode, P);
case 0x470867u:
return PiCMDeleteClassKey(InputBuffer, InputBufferSize, OutputBuffer, OutputBufferSize, PreviousMode, P);
case 0x47086Bu:
return PiCMOpenObjectKey(InputBuffer, InputBufferSize, OutputBuffer, OutputBufferSize, PreviousMode, P);
case 0x47086Fu:
return PiCMCreateObject(InputBuffer, InputBufferSize, OutputBuffer, OutputBufferSize, PreviousMode, P);
}
Множество стандартных кодов определены в заголовочном файле winioctl.h и присутствуют во встроенной библиотеке типов IDA.
2. Мутная арифметика
Представим ситуацию, что нам известен ioctl -
0x39007E, который уязвим (например, из короткого отчета вендора или других источников) и мы хотим проанализировать драйвер.Да, ioctl не может быть уязвим, это просто dword, а уязвимой является функция, которая соответствует данному ioctl, но по-моему так проще и понятнее.
Мы можем начать последовательно начать анализировать драйвер от
DriverEntry до обработчика или просто найти по байтовому паттерну. Это зачастую удается сделать. Нажимаем Ctrl+B, вводим искомое значение и нажимаем OK.
Это работает не всегда. Иногда подсчет и обработка ioctl выражена арифметически. Например, как это происходит в драйвере
mskssrv.sys и функции SrvDispatchIoControl.
C:
Ioctl = Irp->Tail.Overlay.CurrentStackLocation->Parameters.DeviceIoControl.IoControlCode;
if ( Ioctl > 0x2F0418 )
{
// Skip...
}
//
// Handling of the ioctl lower then 0x2F0418
//
if ( Ioctl == 0x2F0418 )
{
P = 0i64;
status = FSGetRendezvousServer(&P);
if ( status >= 0 )
{
retVal = FSRendezvousServer::NotifyContext(P, Irp);
goto Cleanup;
}
goto CompleteRequest;
}
v4 = Ioctl - IOCTL_KS_PROPERTY;
if ( !v4 )
{
v12 = KsPropertyHandler(Irp, 1u, &PropertySet);
goto LABEL_22;
}
v5 = v4 - 0x3FD;
if ( !v5 )
{
v12 = FSInitializeContextRendezvous(Irp);
goto LABEL_22;
}
v6 = v5 - 4;
if ( !v6 )
{
P = 0i64;
status = FSGetRendezvousServer(&P);
if ( status >= 0 )
{
retVal = FSRendezvousServer::InitializeStream(P, Irp);
goto Cleanup;
}
goto CompleteRequest;
}
v7 = v6 - 4;
if ( !v7 )
{
P = 0i64;
status = FSGetRendezvousServer(&P);
if ( status >= 0 )
{
retVal = FSRendezvousServer::PublishTx(P, Irp);// Vulnerable path starts here
goto Cleanup;
}
goto CompleteRequest;
}
Схожий паттерн через вычитание я встречал и в других драйверах. Напомню, что мы должны знать ioctl, чтобы отправить запрос из пользовательского режима драйверу. Так как в данном случае определить все ioctl? Иногда это легко сделать невооруженным глазом и подсчетом в уме, иногда можно применить SMT-решатель, например библиотеку z3, копируя логику из декомпилятора, что я и сделал в этом примере.
Python:
import z3
IOCTL_KS_PROPERTY = 0x2f0003
ioctl = z3.Real('ioctl')
v4 = z3.Real('v4')
v5 = z3.Real('v5')
v6 = z3.Real('v6')
v7 = z3.Real('v7')
z3.solve(ioctl < 0x2f0418,\
ioctl > 0, \
v4 == ioctl - IOCTL_KS_PROPERTY, \
v5 == v4 - 0x3FD, \
v6 == v5 - 4, \
v7 == v6 - 4, \
v7 == 0)
"""Output:
[ioctl = 3081224, v4 = 1029, v5 = 8, v6 = 4, v7 = 0]
"""
Таким образом, нужный ioctl для
FSRendezvousServer::PublishTx равен 0x2f0408, который не будет обнаружен по байтовому паттерну в драйвере.9.Как фильтруются недоверенные указатели?
Отсутствие валидации указателей в ядре может привести к таким уязвимостям как AAR (Arbitrary Address Read), AAW (Arbitrary Address Write) т.е. к произвольному чтению ядерной памяти или записи по ядерному указателю, что может использоваться атакующим для повышения привилегий, отключения защитных решений в результате DKOM.
В ядре Windows и драйверах вы можете встретить следующие паттерны.
1. ProbeForRead, ProbeForWrite
Данные функции проверяют, что весь буфер находится в пространстве пользователя.
C:
// Skip
if ( PreviousMode )
ProbeForWrite(/* UserPtr10 - это пользовательский указатель? */ user_UnkStruct->UserPtr10, Len, Alignment);
// Записать какое-либо значение по указателю
*userUnkStruct->UserPtr10 = ValueToWrite;
Если бы
UserPtr10 никак не валидировался, то мы смогли бы писать по произвольному адресу контролируемое полностью, частично или рандомное значение. Это прямая угроза безопасности.Часто такая проверка еще пристутсвует перед операцией копирования при помощи функции
memmove.
C:
// Skip
// Валидация аргументов
if ( OutBufferSize )
{
if ( OutBufferSize <= 0x100 )
{
if ( PreviousMode )
ProbeForWrite(OutputBuffer, OutBufferSize, 1u);
// SKip
memmove(OutputBuffer, Src, OutputBufferSize);
}
}
2. MmUserProbeAddress
MmUserProbeAddress - это глобальная переменная в ядре Windows, которая также используется для валидации указателей пользовательского режима.
Код:
3: kd> dps nt!MmUserProbeAddress
fffff807`46e20080 00007fff`ffff0000
Как вы можете видеть значение этой переменной равно
0x7fffffff0000. На листинге ниже пример валидации входящего буфера.
C:
NTSTATUS __fastcall AhcDispatch(int FunctionCode, PVOID InputBuffer, KPROCESSOR_MODE RequestorMode)
{
if ( RequestorMode ) // Если UserMode
{
if ( /* Буфер выровнен по границе в 4 байта? */(InputBuffer & 3) != 0 )
ExRaiseDatatypeMisalignment();
if ( /* Проверка, что весь буфер расположен в разрешенном диапазоне адресов */InputBuffer + 0x188 > MmUserProbeAddress || /* Проверка на переполнение */ InputBuffer + 0x188 < InputBuffer )
*MmUserProbeAddress = 0;
}
// Skip
Таким образом, мы можем быстро идентифицировать участки кода, которые работают с пользовательскими данными, нажав
X на переменную или вышеуказанные фукнции и получить все перекрестные ссылки. Однако, если разработчик забыл добавить валидацию, то для идентификации кода, работающего с пользовательскими данными мы можем и другими методами. Об этом я расскажу в точке 10.10. Как определить код обрабатывающий пользовательские данные?
Прежде чем ответить на вопрос, зачем вообще нам нужно определять такие места в коде? Некорректная обработка пользоваетльских данных, как известно, корень зла в любом приложении и может привести к уязвимости или некорректной работе приложения. Следовательно, это приоритетная цель для багхантера или разработчика ядра и драйверов. Можно выделить несколько паттернов, которые используются при обработке пользовательских данных.
1. PreviousMode
При обработке пользовательских данных ядро часто опирается на поле PreviousMode, которое имеет два значения -
UserMode и KernelMode, которые в свою очередь являются лишь числами 1 и 0 соответственно.Больше актуально для ядра Windows, но в драйверах встречается и такой паттерн:
C:
// Восстановленные типы аргументов
BOOL __fastcall AfdRioFastIo(
PFILE_OBJECT FileObject,
PVOID InputBuffer,
unsigned int InBufLength,
PVOID OutputBuffer,
int OutBuffLength,
PIO_STATUS_BLOCK StatusBlock)
// Skip
PreviousMode = ExGetPreviousMode();
// Skip
if ( PreviousMode) // Если UserMode
{
if ( /* Буфер выровнен по границе в 4 байта? */ (InputBuffer & 3) != 0 )
ExRaiseDatatypeMisalignment();
if ( /* Проверка, что весь буфер расположен в разрешенном диапазоне адресов */ InputBuffer + 4 > MmUserProbeAddress || /* Проверка на переполнение */ InputBuffer + 4 < InputBuffer )
*MmUserProbeAddress = 0;
}
// Skip
{
Как вы можете догадаться функция
ExGetPreviousMode() получает значение из структуры _KTHREAD.
C:
KPROCESSOR_MODE ExGetPreviousMode(void)
{
return KeGetCurrentThread()->PreviousMode;
}
В ядре вызов этой функции просто инлайнится и в остальном обработка пользовательских данных ничем не отличается.
2. RequestorMode
IRP-обработчики часто опираются на схожий механизм, но обращаются к аналогичному полю в структуре
_IRP.
C:
NTSTATUS __fastcall AhcDriverDispatchDeviceControl(PDEVICE_OBJECT DeviceObject, IRP *Irp)
{
// Skip
status = AhcDispatch(/* код ioctl */ ControlCode,
/* Указатель на пользовательский буфер */ CurrentStackLocation->Parameters.DeviceIoControl.Type3InputBuffer,
/* Аналогично PreviousMode */ Irp->RequestorMode);
}
Здесь помимо RequestorMode есть жирный намек на обращение к данным в режиме пользователья - это поле
Type3InputBuffer. Название, не самое информативное для Windows, на мой взгляд (я не припомню, чтобы терминология с кольцами была как-то распространена в Windows, тем более, что нет поля с названием Type0InputBuffer), но это поле говорит, что буфер находится в ring3, а ring3 - это, как известно, пользовательский режим.В самом же вызове
AhcDispatch логика обработки пользовательских данных аналогична примеру выше, т.е. проверка верхней границы пользовательского буфера, проверка на целочисленное переполнение и выровнен ли адрес по 4 байтовой границе.
C:
NTSTATUS __fastcall AhcDispatch(int FunctionCode, PVOID InputBuffer, KPROCESSOR_MODE RequestorMode)
{
if ( RequestorMode ) // Если UserMode
{
if ( /* Буфер выровнен по границе в 4 байта? */(InputBuffer & 3) != 0 )
ExRaiseDatatypeMisalignment();
if ( /* Проверка, что весь буфер расположен в разрешенном диапазоне адресов */ InputBuffer + 0x188 > MmUserProbeAddress || /* Проверка на переполнение */ InputBuffer + 0x188 < InputBuffer )
*MmUserProbeAddress = 0;
}
// Skip
}
Здесь мы немного пересеклись с 9 точкой, но без этого ничего бы не вышло.
13. Ошибки декомпилятора
13.1. Ошибка применения enum'ов.
При документировании базы я стараюсь применять enum'ы ко всем "магическим числам" для удобства переваривания информации. Иногда IDA почему-то не может найти соответствующее числу символьное значение enum'а.
В данном случае я точно знаю, что это значение
STATUS_INSUFFICIENT_RESOURCES. Решением этой проблемы является явное указание возвращаемого значения прототипа функции NTSTATUS.После этого мы можем применить символ.
15. Как отлаживать драйверы без .pdb?
Увы, но не все драйвера имеют отладочную информацию и мы должны с этим как-то работать. Лично я нашел для себя оптимальное решение - это плагин [ret-sync](https://github.com/bootleg/ret-sync). Я регулярно его использую с любыми драйверами, т.к. он позволяет синхронизировать окно дизассемблера отладчика с аналогичным окном в IDA или же выводом декомпилятора. Этот плагин может использоваться также с Ghidra, Binary Ninja. Поскольку мы не имеем отладочной информации, то нам придется ставить точки останова по адресам. Кроме этого необходимо изменить базовый адрес драйвера в IDA, чтобы он соответствовал базовому адресу драйвера, загруженного в память на отлаживаемой машине. Алгоритм действий может выглядеть следующим образом:
1. Отлавливаем загрузку драйвера, как это уже было описано в путеводителе ранее при помощи команды
sxe ld <driver.sys>.2. Определяем базовый адрес загруженного драйвера.
Код:
0: kd> lm m redacted
Browse full module list
start end module name
fffff804`5ffb0000 fffff804`5ffb9000 redacted.sys (no symbols)
3. В IDA выбираем Edit->Segments->Rebase Program..., копируем адрес и нажимаем OK.
4. В IDA запускаем плагин ret-sync и обязательно указываем синхронизацию с выводом декомпилятора.
5. Загружаем и запускаем плагин в отладчике.
Код:
0: kd> .load sync
0: kd> !sync
[sync] sync update
Теперь отладчик и база полностью синхронизированы. Мы можем ставить точки останова по любому адресу и наслаждаться той информативностью, что нам дает IDA.
Последнее редактирование: