НеоБИТ corporate blog
Information Security
Entertaining tasks
Reverse engineering
CTF
April 11

Погружение в драйвер: общий принцип реверса на примере задания NeoQUEST-2019


Как и все программисты, ты любишь код. Вы с ним — лучшие друзья. Но рано или поздно в жизни наступит такой момент, когда кода с тобой не будет. Да, в это сложно поверить, но между вами будет огромная пропасть: ты снаружи, а он — глубоко внутри. От безысходности тебе, как и всем, придется перейти на другую сторону. На сторону обратной разработки.

На примере задания №2 из online-этапа NeoQUEST-2019 разберем общий принцип реверса драйвера Windows. Конечно, пример является довольно упрощенным, но суть процесса от этого не меняется — вопрос только в объеме кода, который нужно просмотреть. Вооружившись опытом и удачей, приступим!

Дано


По легенде нам выданы два файла: дамп трафика и бинарный файл, который этот самый трафик и генерировал. Взглянем сначала на дамп с помощью Wireshark:


В дампе находится поток UDP-пакетов, каждый из которых содержит 6 байт данных. Эти данные, на первый взгляд, представляют собой какой-то случайный набор байт — что-либо вытащить из трафика не представляется возможным. Поэтому переключим свое внимание на бинарь, который должен подсказать, как все расшифровать.
Откроем его в IDA:


Похоже, что перед нами какой-то драйвер. Функции с префиксом WSK относятся к Winsock Kernel – сетевому программному интерфейсу режима ядра Windows. На MSDN можно посмотреть описание структур и функций, используемых в WSK.

Для удобства можно загрузить в IDA библиотеку Windows Driver Kit 8 (kernel mode) – wdk8_km (или любую более новую), чтобы использовать определенные там типы:


Осторожно, реверс!


Как всегда, начинаем с точки входа:


Пойдем по порядку. Сначала производится инициализация Wsk, создается и биндится сокет – подробно описывать данные функции не будем, они не несут никакой полезной для нас информации.

Функция sub_140001608 устанавливает 4 глобальные переменные. Назовем ее InitVars. В одну из них записывается значение, лежащее по адресу 0xFFFFF78000000320. Немного погуглив данный адрес, можно сделать предположение, что по нему записано количество тиков системного таймера с момента загрузки системы. Пока что назовем переменную TickCount.


Далее в EntryPoint устанавливаются функции для обработки IRP-пакетов (I/O Request Packet). Подробнее про них можно почитать на MSDN. Для всех типов запросов определяется функция, которая просто передает пакет следующему драйверу в стеке.


А вот для типа IRP_MJ_READ (3) определена отдельная функция; назовем ее IrpRead.



В ней, в свою очередь, устанавливается CompletionRoutine.


CompletionRoutine заполняет неизвестную структуру данными, полученными из IRP, и помещает ее в список. Пока что нам неизвестно, что находится внутри пакета — вернемся к этой функции позже.
Смотрим дальше в EntryPoint. После определения обработчиков IRP, происходит вызов функции sub_1400012F8. Заглянем внутрь и сразу заметим, что в ней создается девайс (IoCreateDevice).


Назовем функцию AddDevice. Если правильно привести типы, то мы увидим, что имя девайса – "\\Device\\KeyboardClass0". Значит, наш драйвер взаимодействует с клавиатурой. Погуглив про IRP_MJ_READ в контексте клавиатуры, можно найти, что в пакетах передается структура KEYBOARD_INPUT_DATA. Вернемся к CompletionRoutine и посмотрим, что за данные она передает.


IDA здесь плохо парсит структуру, но по смещениям и дальнейшим вызовам можно понять, что она состоит из ListEntry, KeyData (здесь хранится скан-код клавиши) и KeyFlags.
После AddDevice в EntryPoint вызывается функция sub_140001274. Она создает новый поток.


Посмотрим, что происходит в ThreadFunc.


Она получает значение из списка и обрабатывает их. Сразу обратим внимание на функцию sub_140001A18.


Она передает обработанные данные на вход функции sub_140001A68, вместе с указателем на WskSocket и числом 0x89E0FEA928230002. Разобрав число-параметр по байтам (0x89 = 137, 0xE0 = 224, 0xFE = 243, 0xA9 = 169, 0x2328 = 9000), мы получим как раз те самые адрес и порт из дампа трафика: 169.243.224.137:9000. Логично предположить, что эта функция отправляет сетевой пакет на указанный адрес и порт — рассматривать подробно ее не будем.
Разберемся, каким образом обрабатываются данные перед отправкой.

Для первых двух элементов выполняют эквиваленцию со сгенерированным значением. Так как для вычисления используется количество тиков, то можно предположить, что перед нами генерация псевдослучайного числа.



После генерации числа оно перезаписывает собой значение переменной, ранее названной нами TickCount. Переменные для формулы задаются в InitVars. Если мы вернемся к вызову этой функции, то узнаем значения для этих переменных, и в итоге получим следующую формулу:

(54773 + 7141 * prev_value) % 259200

Это линейный конгруэнтный генератор псевдослучайных чисел. Он инициализируется в InitVars с использованием TickCount. Для каждого последующего числа в качестве исходного значения выступает предыдущее (причем генератор возвращает двухбайтовое значение, и такое же используется для последующей генерации).


После эквиваленции со случайным числом двух значений, переданных от клавиатуры, вызывается функция, формирующая оставшиеся два байта сообщения. Она просто производит xor двух уже зашифрованных параметров и некоего константного значения. Это врядли позволит как-то расшифровать данные, поэтому последние два байта сообщения для нас не несут какой-либо полезной информации, и их можно не рассматривать. Но что делать с зашифрованными данными?
Давайте внимательнее посмотрим на то, что именно шифруется. KeyData – это скан-код, может принимать довольно широкий диапазон значений, угадать его не просто. А вот KeyFlags представляет собой битовое поле:


Если посмотреть таблицу скан-кодов, то можно заметить, что чаще всего флаг будет либо 0 (клавиша опущена), либо 1 (клавиша поднята). KEY_E0 будет выставлен достаточно редко, но может попадаться, а вот встретить KEY_E1 шансы очень малы. Поэтому можно попробовать сделать следующее: проходим по данным из дампа, выбираем значение, которое является зашифрованным KeyFlags, производим эквиваленцию с 0, генерируем два следующих друг за другом ПСЧ. Во-первых, KeyData представляет собой один байт, и мы можем проверить правильность сгенерированного ПСЧ по старшему байту. А во-вторых, следующий зашифрованный KeyFlags, при произведении эквиваленции с правильным ПСЧ, будет принимать те же самые значения бит. Если же это оказалось не так, то мы принимаем, что KeyFlags, который мы изначально рассматривали, был равен 1, и т.д.
Попробуем реализовать наш алгоритм. Для этого воспользуемся python:

Реализация алгоритма
# соответствие скан-кодов и клавиш
keymap = […]

# данные, полученные из Wireshark
traffic_dump = […]

# эквиваленция
def bxnor(a, b):
    return ((~a & 0xffff) | b) & (a | (~b & 0xffff))

# генерация ПСЧ
def brgen(a):
    return ((7141 * a + 54773) % 259200) & 0xffff

def decode():
    # проходим по всему дампу
    for i in range(0, len(traffic_dump) - 1):
        # берем зашифрованный KeyFlags
        probe = traffic_dump[i][1]
        # берем зашифрованный скан-код
        scancode = traffic_dump[i+1][0]
        # берем следующий зашифрованный KeyFlags
        tester = traffic_dump[i+1][1]
        fail = True

        # пробегаем по возможным значениям (не рассматривая KEY_E1)
        for flag in range(4):
            rnd_flag = bxnor(flag, probe)
            rnd_sc = brgen(rnd_flag)
            next_flag = bxnor(tester, brgen(rnd_sc))
            # проверяем следующий KeyFlags
            if next_flag in range(4):
                sc = bxnor(rnd_sc, scancode)
                if sc < len(keymap):
                    sym = keymap[sc]
                    if next_flag % 2 == 0:
                        print(sym, end='')
                fail = False
                break
        # если на каком-то этапе ни один из вариантов KeyFlags не сработал
        if fail:
            print('Something went wrong on {} pair'.format(i))
            return
    print()

if __name__ == "__main__":
    decode()


Запустим наш скрипт на полученных из дампа данных:


И в расшифрованном трафике обнаруживаем нашу самую желанную строчку!

NQ2019DABE17518674F97DBA393415E9727982FC52C202549E6C1740BC0933C694B3DE


В скором времени выйдут статьи с разборами остальных заданий, не пропустите!

P.S. А мы напоминаем, что всем, кто прошел полностью хотя бы одно задание на NeoQUEST-2019, полагается приз! Проверяйте почту на наличие письма, а если вдруг оно вам не пришло — пишите на support@neoquest.ru!

+15
2.7k 30
Comments 2
Top of the day