• XSS.stack #1 – первый литературный журнал от юзеров форума

Мануал/Книга Путеводитель по драйверам Windows для новичков и не только

varwar

El Diff
Забанен
Регистрация
12.11.2020
Сообщения
1 383
Решения
5
Реакции
1 537
Пожалуйста, обратите внимание, что пользователь заблокирован
ДИСКЛЕЙМЕР!
Это черновая версия путеводителя, которая будет изменяться и дополняться с учетом замечаний и пожеланий. Путеводитель рассчитан на непоследовательное чтение, поэтому каждый может добавить собственную точку маршрута по какой-либо актуальной проблеме, связанной с драйверами, ядром ОС 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 - сетевые драйвера
2. DriverEntry

Допустим, вы нашли какой-то интересующий драйвер и открыли его в 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

Убрать исключение можно командой 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. Как узнать назначение аргумента в неизвестной функции?

Данную проблему можно отнести к основным проблемам при разборе любого приложения. Зачастую я пользуюсь следующими методами:
  1. Анализ функций в обратном порядке
  2. Анализ функции в контексте
  3. Проверка гипотезы при помощи отладчика
  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 на имени функции.

4.1.png


Скажу сразу, что функции NtAlpcOpenSenderThread и NtAlpcOpenSenderProcess довольно быстро приведут нас к ответу, но я все же покажу на более сложном примере, потому что с таким вы тоже будете сталкиваться. Для этого примера выберем AlpcpProcessConnectionRequest.

C:
if ( a3 )
        {
            AlpcpProbeForWriteMessageHeader(a3, a2);
            AlpcpProbeAndCaptureMessageHeader(a3, &v38, a2);
        }

Здесь мы видим, что два аргумента, которые передаются в интересующую нас функцию являются также аргументами функции AlpcpProcessConnectionRequest. Второй аргумент &v38 - это адрес стековой переменной. Для наглядности соотнесем имеющуюся информацию.

4.2.png


Повторяем процедуру.

4.3.png


Мы можем в процессе анализировать код функций и строить новые гипотезы, используя второй метод. Пока мы продолжаем подниматься наверх в надежде, что это приведет нас куда-нибудь. И действительно, на четвертой функции мы попадаем в системный вызов NtAlpcConnectPort, который содержит интересующие нас аргументы.

4.4.png


На этом наша змейка не заканчивается. Проблема в том, что в .pdb нет прототипов функций для подсистемы ALPC. В отличие от других системных вызовов этот недокументирован... т.е. официально.
Если мы загуглим в поиске этот сискол, то найдем прототипы функций и типы для ALPC в системной утилите Process Hacker. Назначаем все типы вручную для NtAlpcConnectPort и продолжаем змейку в обратную сторону.

4.5.png


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 начинаются с нижнего подчеркивания.

4.6.png


Кажется то, что нужно. Теперь мы можем явно указать тип в прототипе функции AlpcpLookupMessage как _ALPC_PORT* вместо PVOID. Весь список объектов можно посмотреть утилитой WinObjEx.

4.7.png



Таким образом мы просто находим перекрестные ссылки аргумента внутри исследуемой функции и пытаемся использовать имеющуюся информацию вызываемых функций по отношению к нашей. Найти все вхождения какой-либо переменной в IDA можно таким же нажатием X.

Самое очевидное в таких случаях - найти в перекрестных ссылках какую-нибудь документированную функцию с известными типами.

IDA зачастую такую информацию о типах обрабатывает рекурсивно, но не всегда.

4.8.png


Все, что мы делали выше - мы делали в статике. Если есть сомнения, то мы пользуемся третьим методом - отладчиком. Ставим брейкпоинт на нужной функции и проверяем аргументы встроенными командами !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 по моим наблюдениям ВСЕГДА пишутся в верхнем регистре. Это маленькое знание поможет отсеивать ненужные значения, которые по какой-либо причине совпадают с искомым.

На скриншоте ниже мы можем видеть всего два значения, которые совпадают. Если мы вернемся и посмотрим на название нашей функции, то ответ станет очевиден.

4.9.png


Применим ту же тактику по отношении к двум оставшимся флагам. По-моему, теперь код выглядит намного чище и информативней. Человек, читающий нашу базу будет благодарен.

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

// И т.д.
Случается и такое, что обработчик ioctl находится в другом драйвере. Например, вы можете встретить такой паттерн.

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.

8.1.png


8.2.png


Это работает не всегда. Иногда подсчет и обработка 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'а.

13.1.png


В данном случае я точно знаю, что это значение STATUS_INSUFFICIENT_RESOURCES. Решением этой проблемы является явное указание возвращаемого значения прототипа функции NTSTATUS.
После этого мы можем применить символ.

13.2.png


13.3.png


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.
15.1.png


4. В IDA запускаем плагин ret-sync и обязательно указываем синхронизацию с выводом декомпилятора.

15.2.png


5. Загружаем и запускаем плагин в отладчике.

Код:
0: kd> .load sync
0: kd> !sync
[sync] sync update

Теперь отладчик и база полностью синхронизированы. Мы можем ставить точки останова по любому адресу и наслаждаться той информативностью, что нам дает IDA.

15.3.png
 
Последнее редактирование:
По поводу читаемости псевдокода ИДЫ. Ида сама по себе может вытворять странные и даже жуткие вещи, порой глянешь на псевдокод и кажется будто происходит какая то шизофрения.
На канале SpbCtf видео, где объясняется как некоторые такие закидоны исправлять руками.
 
Пожалуйста, обратите внимание, что пользователь заблокирован
По поводу читаемости псевдокода ИДЫ. Ида сама по себе может вытворять странные и даже жуткие вещи, порой глянешь на псевдокод и кажется будто происходит какая то шизофрения.
На канале SpbCtf видео, где объясняется как некоторые такие закидоны исправлять руками.
Это правда, об этом будет написано в "ошибках декомпилятора", но все в контексте драйверов, т.к. там есть свои приколы и они не завязаны на конкретный инструмент, аналогичные ошибки будут и у других продуктов. Не стал еще упоминать, что нужно сверяться с дизом, т.к. в декомпиле иногда кропаются целые блоки кода. Думал, что это очевидно, но тогда тоже упомяну этот момент. Надо только вспомнить где я это встречал и найти реальный пример.
 
Пожалуйста, обратите внимание, что пользователь заблокирован
Добавил пункты 9, 10, 15.
Немного обновил про отлов драйверов.
 
Последнее редактирование:
Пожалуйста, обратите внимание, что пользователь заблокирован
продолжение будет?
Разумеется. Делаю по мере возможностей. Блок с документированием еще полноценно не осмыслен и не оформлен.
 
Can anyone translate this into English? I mean online translators are there too but they often do a pretty crappy job of translation, specially when it comes to guides like this one.
Anyhow, this looks like some amazing work for someone like me who has recently started to learn C and been wanting to learn more about Windows Drivers and Internals.
 
Пожалуйста, обратите внимание, что пользователь заблокирован
who has recently started to learn C and been wanting to learn more about Windows Drivers and Internals.
To start learn more about Windows Drivers and Internals I suggest you to read books, documentation and source codes. This guide designed (more or less) from reverse engineering perspective, but of course might me helpful to others.

Can anyone translate this into English?
Manual is not over yet and too raw for translation. Each part mostly independent and can be read with translators. It's not from cover to cover book or something, just tips.
 
To start learn more about Windows Drivers and Internals I suggest you to read books, documentation and source codes. This guide designed (more or less) from reverse engineering perspective, but of course might me helpful to others.
Ah, understood, my reasons to learn about Windows Drivers and Internals is to get into Malware Development and Red Teaming. I just purchased the Mal Dev Academy Membership but before all of that, I think I need to brush up my C first as I do not know the first thing about writing code other than printing Hello World or modifying Python scripts or using python to automate tasks.

Manual is not over yet and too raw for translation. Each part mostly independent and can be read with translators. It's not from cover to cover book or something, just tips.

In that case, online translations are my best friend.
 


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх