R0 CREW

Распаковка драйвера режима ядра

Оригинал: x64dbg.com

Недавно, друг попросил меня взглянуть на запакованный драйвер ядра. Я решил взяться за дело и это оказался достаточно интересный опыт.

Вам потребуется:

Не читайте дальше, если вам интересно попробовать решить задачу самим. Хеши образцов доступны в самом конце данной статьи.

Начальный анализ

Взгляд на файл в CFF Explorer даст нам базовое представление о нём. Он нативный (OptionalHeader->Subsystem = Native) исполняемый файл 64-битной архитектуры с кучей импортов из fltmgr.sys. Из ntoskrnl.exe присутствует всего один импорт в виде функции MmIsAddressValid. Это уже выглядит подозрительно, потому что даже такой миниатюрный драйвер как beep.sys импортирует сразу 25 функций из ntoskrnl.exe.

Чтобы открыть этот файл в x64dbg, нам придётся сделать несколько изменений в PE заголовке. Во первых, направляемся в Optional Header и меняем Subsystem (почти в самом конце списка) c Native на Windows GUI. Этот шаг позволит Windows загрузить драйвер как приложение режима пользователя. После сохранения файла как aksdf.exe и загрузки его в x64dbg нас встретит сообщение об ошибке:

Причина ошибки в том, что драйвер попытался загрузить ntoskrnl.exe и/или fltmgr.sys в исполняемое пространство процесса, но так как это нативные исполняемые файлы, то у загрузчика ничего не вышло. В дополнение к этому, некоторые директории PE файла выглядят “поврежденными” (как минимум, для загрузчика пользовательского режима), но это тема для другого исследования.

Имитация экспорта модулей ядра

Чтобы имитировать экспорты ntoskrnl.exe и ftlmgr.sys, я написал небольшую программу на C#. Она принимает на вход имя модуля и таблицу экспорта из CFF Explorer (Ctrl+A, Ctrl+C):

using System;
using System.Collections.Generic;
using System.IO;
using System.Globalization;

namespace faker
{
    class Program
    {
        static void Main(string[] args)
        {
            if (args.Length < 2)
            {
                Console.WriteLine("Usage: faker libname exports.txt");
                return;
            }
            var def = new List<string>();
            def.Add(string.Format("LIBRARY \"{0}\"", args[0]));
            def.Add("EXPORTS");
            var fake = new List<string>();
            fake.Add("#define FAKE(x) void* x() { return #x; }");
            foreach (var line in File.ReadAllLines(args[1]))
            {
                var split = line.Split(' ');
                var ord = int.Parse(
                    split[0].TrimStart('0'),
                    NumberStyles.HexNumber);
                var name = split[split.Length - 1];
                if (name == "N/A")
                {
                    def.Add(string.Format("noname{0} @{0} NONAME", ord));
                    fake.Add(string.Format("FAKE(noname{0})", ord));
                }
                else
                {
                    def.Add(string.Format("{0}={0}_FAKE @{1}", name, ord));
                    fake.Add(string.Format("FAKE({0}_FAKE)", name));
                }
            }
            def.Add("");
            File.WriteAllLines(args[0] + ".def", def);
            File.WriteAllLines(args[0] + ".cpp", fake);
        }
    }
}

После того, как программа закончит свою работу, вы получите fltmgr.cpp и fltmgr.def, которые нужно добавить в пустой проект динамической библиотеки в Visual Studio и затем скомпилировать, получив в результате DLL с экспортами, которые идеально соответствуют тем, что есть у реального драйвера. Полный исходный код инструмента доступен здесь, соответствующие скомпилированые файлы можно найти в разделе release.

Финальным шагом будет перемещение поддельных ntoskrnl.exe и fltmgr.sys в директорию, где находится наша цель – aksdf.exe. После проделанных действий x64dbg дойдет до точки входа, которая выглядит следующим образом:

В твиттере мне прислали ссылку на альтернативную библиотеку (с бОльшим числом имитируемых функций), которой вы можете воспользоваться.

Распаковка

Попытавшись немного исполнить пошагово код (stepping) вы можете заметить, что код трудно понять. Огромное количество избыточных выражений и ветвлений внутрь других инструкций. Читатель может незначительно улучшить читабельность путем нажатия B (ПКМ -> Analysis -> Treat from selection as -> Byte), делая мусорные байты данными, но я бы не рекомендовал этот метод, так как Single Stepping значительно более простая альтернатива.

Пройдя немного дальше, увидим, что адрес функции MmIsAddressValidсохраняется на стек (та самая, подозрительная функция):

23F02 lea rax,qword ptr ds:[<&MmIsAddressValid>]
23F09 push qword ptr ds:[rax]

Потрассировав еще немного вперед встречаем инструкцию, которая генерирует исключение:

23D46 mov dr7,rax

Код исключения – EXCEPTION_PRIV_INSTRUCTION, которое вполне обосновано, потому что драйвер загружен в режиме пользователя. Значение, которое записывалось в dr70x400(10 бит выставлен). Я думаю, это отключает все активные Hardware Breakpoint - hwbp (если таковые были). По причине того, что мы выполняем отладку не в режиме ядра, добавляем hwbp, чтобы автоматически пропускать инструкцию, которая выбрасывает такое исключение:

Затем отредактируем точку останова и установим следующие поля:

Перезапустим исследуемое приложение и когда мы вновь окажемся на точке входа, то включим запись трассы исполнения (ПКМ -> Trace record -> Word). Также стоит забиндить клавишу (я использую комбинацию Ctrl+/) на действие Debug -> Trace into beyond record.

Если сделаете все правильно, нажатие на выбранную клавишу (в моём случае Ctrl+/) позволит останавливать исполнение только на инструкциях, которые еще ни разу не исполнялись. Это может быть крайне полезно в обфусцированном коде, так как ветвления бывают очень запутанными и сохранённая трасса может помочь понять, какие кусочки кода вы уже видели:

После некоторого времени нажимания комбинации Ctrl+/ трассировка будет с каждым разом требовать все больше и больше времени и в конце концов вы остановитесь где-нибудь на инструкции ret. После шага сквозь эту инструкцию нажмите G и затем O, чтобы переключиться в режим представления графа:

В графе все ноды окрашены в зависимости от того, что в них находится. Красные – так называемые, завершающие ноды (terminal nodes). Они обычно оканчиваются инструкциями ret или jmp reg. Синие ноды – содержат косвенные вызовы. В данном случае:

24044 call qword ptr ds:[r14]

Установите hwbp на оба этих вызова и запустите программу. Вы уведите, что вызываемая функция – это MmIsAddressValid, которая (очевидно) проверяет корректность переданного адреса. Для продолжения, нам необходимо добавить реализацию этой функции в поддельный ntoskrnl.exe:

#include <windows.h>

#pragma optimize("", off)
BOOL MmIsAddressValid_FAKE(LPCVOID addr)
{
    __try
    {
        auto x = *(char*)addr;
        return TRUE;
    }
    __except(EXCEPTION_ACCESS_VIOLATION)
    {
        return FALSE;
    }
}
#pragma optimize("", on)

Перезапустив исполняемый файл и дойдя до того же самого места (hwbp должна сохранится в базе данных, так что вам не придется устанавливать её снова). После перешагивания (Step Over) через вызов MmIsAddressValid и пропуск неинтересного кода, перед нами начинает проявляться следующее (немного деобфусцировано мной):

@again:
24037 sub r15,1000
2403E mov rcx,r15
24044 call qword ptr ds:[r14] ; 'MmIsAddressValid'
2404A or al,al
2404C je @again
24060 mov dx,5A4D ; 'MZ'
24064 mov rax,r15
24067 cmp dx,word ptr ds:[rax]
24074 jne @again

Этот код выполняет поиск начала заголовка PE. После нахождения, код проверит сигнатуру “PE” (MmIsAddressValid использяется перед её чтением). Продолжайте трассировку пока не достигните инструкции call.

Как оказалось, этот вызов (я назвал его resolveImport) используется, чтобы найти функцию импортируемую из ntoskrnl.exe. resolveImport прочитывает таблицу экспорта переданого ей модуля и вызывает hashExportName на каждом из имен. Если имя совадает с требуемым хешем, виртуальный адрес экспортируемой функции возвращяется вызывающей стороне.

Я оставляю читателю возможность самостоятельно выяснить, как работает цикл нахождения импортируемых функций. Грубо говоря, все что я сделал – это поставил hwbp на запись, где предположительно должен был быть сохранён адрес разрезолвленой последней функции. Это позволит нам попасть на самую последнюю итерацию цикла. Еще немного трассировки и вы увидите группу инструкций pop (чтобы восстановить оригинальные значения регистров) и наконец нас ждет “приземление” на оригинальную точку входа (OEP):

22100 mov qword ptr ss:[rsp+10],rdx
22105 mov qword ptr ss:[rsp+8],rcx
2210A sub rsp,C8
22111 mov byte ptr ss:[rsp+40],0
22116 mov qword ptr ss:[rsp+48],0
2211F mov rax,qword ptr ss:[rsp+D0]
22127 mov qword ptr ds:[201C0],rax
2212E call aksdf.10C00

Рассуждения о быстром методе распаковки

Посколько мы знаем как выглядит OEP, возможно найти более быстрый путь распаковать исполняемый файл. Взгляните, например, на точку входа:

23E5E lea rax,qword ptr ds:[22100] ; loads the address of oep in rax

Простая установка hwbp на адрес 0x22100 позволит попасть на OEP. Другой метод – широко известный трюк с установкой hwbp на [rsp], после того как группа оригинальных значений регистров была сохранена на стек:

Снятие дампа и восстановление образа

По причине того, что исполняемый файл имеет странноватое выравнивание (0x80), большинство инструментов (включая Scylla) не смогут сдампить этот файл. Я смог заставить работать только CHimpREC.

Перед тем как мы сдампим файл, есть несколько проблем требующих исправления:

  1. Переходы по адресу 0x1E200 ведут в никуда:

  1. Импорты по какой-то причине разрознены (RtlInitUnicodeString на 0x10EE8 против PsSetCreateProcessNotifyRoutine на 0x225C5)

Поправить первую проблему достаточно просто. Когда я проверил в CFF Explorer оказалось, что 0x1E8000 на самом деле начальный адрес IAT мудуля fltmgr.sys. Кажется, загрузчик Windows не ожидал подобного формата (опять выравнивание?) для приложения режима пользователя и по-тихому не заполнил таблицу адресов импортированных функций самостоятельно.

Немного копирования таблицы экспорта из CFF Explorer и преобразования её с помощью правил RegExp, позволяет сгенерировать x64dbg скрипт, который заполнит IAT как положено. Но убедитесь, что вы обновили x64dbg, так как команда loadlib оказалась сломана в моей версии…

loadlib fltmgr.sys
base=aksdf:$E800
i=0
[base+i*8]=fltmgr:FltCloseClientPort;i++
[base+i*8]=fltmgr:FltReleaseContext;i++
[base+i*8]=fltmgr:FltSetVolumeContext;i++
[base+i*8]=fltmgr:FltGetDiskDeviceObject;i++
[base+i*8]=fltmgr:FltGetVolumeProperties;i++
[base+i*8]=fltmgr:FltAllocateContext;i++
[base+i*8]=fltmgr:FltStartFiltering;i++
[base+i*8]=fltmgr:FltFreeSecurityDescriptor;i++
[base+i*8]=fltmgr:FltCreateCommunicationPort;i++
[base+i*8]=fltmgr:FltBuildDefaultSecurityDescriptor;i++
[base+i*8]=fltmgr:FltUnregisterFilter;i++
[base+i*8]=fltmgr:FltRegisterFilter;i++
[base+i*8]=fltmgr:FltObjectDereference;i++
[base+i*8]=fltmgr:FltCloseCommunicationPort;i++
[base+i*8]=fltmgr:FltGetVolumeFromName;i++
[base+i*8]=fltmgr:FltClose;i++
[base+i*8]=fltmgr:FltFlushBuffers;i++
[base+i*8]=fltmgr:FltQueryInformationFile;i++
[base+i*8]=fltmgr:FltCreateFileEx;i++
[base+i*8]=fltmgr:FltParseFileName;i++
[base+i*8]=fltmgr:FltReleaseFileNameInformation;i++
[base+i*8]=fltmgr:FltGetFileNameInformation;i++
[base+i*8]=fltmgr:FltSetCallbackDataDirty;i++
[base+i*8]=fltmgr:FltSetInformationFile;i++
[base+i*8]=fltmgr:FltSendMessage;i++
[base+i*8]=fltmgr:FltGetBottomInstance;i++
[base+i*8]=fltmgr:FltFreePoolAlignedWithTag;i++
[base+i*8]=fltmgr:FltDoCompletionProcessingWhenSafe;i++
[base+i*8]=fltmgr:FltReadFile;i++
[base+i*8]=fltmgr:FltGetRequestorProcess;i++
[base+i*8]=fltmgr:FltLockUserBuffer;i++
[base+i*8]=fltmgr:FltAllocatePoolAlignedWithTag;i++
[base+i*8]=fltmgr:FltGetVolumeContext;i++
[base+i*8]=fltmgr:FltGetFilterFromInstance;i++
[base+i*8]=fltmgr:FltGetVolumeFromInstance;i++
[base+i*8]=fltmgr:FltWriteFile;i++
[base+i*8]=fltmgr:FltGetTopInstance;i++
[base+i*8]=fltmgr:FltIsOperationSynchronous;i++
[base+i*8]=fltmgr:FltFsControlFile;i++
[base+i*8]=fltmgr:FltCompletePendedPreOperation;i++
[base+i*8]=fltmgr:FltCancelIo;i++
[base+i*8]=fltmgr:FltSetCancelCompletion;i++
[base+i*8]=fltmgr:FltClearCancelCompletion;i++
[base+i*8]=fltmgr:FltParseFileNameInformation;i++
[base+i*8]=fltmgr:FltGetVolumeFromFileObject;i++
ret

После запуска все переходы выглядили вполне нормально:

Вторая проблема также легкоисправима, блоагодаря SmilingWolf и его замечательному инструменту, под названием WannabeUIF. Эта программы позволяет переместить таблицу импорта. Просто введите границы, в которых находится код, и она сделает всю рабту за вас:

Как только сделаете, вы сможете воспользоваться CHimpREC чтобы сдампить и исправить исполняемый файл. Убедитесь, что вы отметили опцию Rebuil Original FT:

Открыв сдампленый файл в x64dbg мы сразу оказываемся на OEP. Очевидно вы не можете сделать что-то большее дальше, просто потому что находитесь в режиме пользователя, но смена Subsystem обратно на Native и открытие файла в IDA должно позволить вам начать анализ. Вы даже, наверное, сможете запустить этот драйвер в режиме Test Signing, если подпишите его личным сертификатом, но я сам не стал это пробовать.

Заключение

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

Базу данных (File -> Import database) для файла aksdf.exe можно найти здесь. Я поработал немного над функциями разрешения импорта и хеширования, чтобы вам был лучше понятен код. Там также добавлены комментарии и проименованы некоторые адреса, чтобы помочь сориентироваться в коде.

Надеюсь, скоро встретиться с вами вновь!

Хеши (образцы, что были рассмотрены в заметке)

MD5: 3190c577746303ca4c65114441192fe2
SHA1: e97cd85c0ef125dd666315ea14d6c1b47d97f938
SHA256: aee970d59e9fb314b559cf0c41dd2cd3c9c9b5dd060a339368000f975f4cd389

VirusTotal, Hybrid-Analysis.

Хеши (другой образец)

MD5: db262badd56d97652d5e726b7c2ed9df
SHA1: 31a4910427f062c4641090b3721382fc7cf88648
SHA256: 55bb0857c9f5bbd47ddc598ba67f276eb264f1fe225a06c6546bf1556ddf60d4

VirusTotal, Hybrid-Analysis.

© Translated by sysenter special for r0 Crew
1 Like