Пишем свою «песочницу»

Доброго времени суток дорогой читатель! Хочу рассказать о написании простой «песочницы». Если вам случалось писать большие приложения, вы, вероятно, использовали такие виртуальные машины, как VMWare, Virtual PC или что-то иное. Но задавались ли вы вопросом: как они работают? Эти удивительные, можно сказать, магические технологии увлекали меня довольно долгое время. Чтобы развенчать «магию» и разобраться в деталях, я написал «с нуля» собственную систему виртуализации – «песочницу». Решение этой задачи было довольно сложным делом. Реализация подобного продукта ставит множество вопросов, ответы на которые вы не найдете в Google, поэтому я хочу поделиться своим опытом с вами.

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

Разработка виртуальной машины – задача не для начинающих программистов, так что я предполагаю, вы имеете опыт в программировании для Windows, и, в частности:

• вы хорошо пишете на C/C++;
• имеете опыт в Win32 API программировании;
• прочли книги о внутреннем устройстве Windows (например, книги Марка Руссиновича);
• имеете базовое представление о сборке софта.

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

Виртуальные машины и песочницы – в чём разница?

Виртуальные машины следовало бы разделить на два больших класса – «тяжелые» виртуальные машины, т.е. полностью эмулирующие железо, и легковесные виртуальные машины, которые эмулируют критически важные части операционной системы, такие как файловая система, реестр и некоторые другие примитивы ОС (например, мьютексы). Примеры легковесных виртуальных машин: Featherweight Virtual Machine, Sandboxie, Cybergenic Shade.

Featherweight Virtual Machine является проектом с открытым исходным кодом, однако есть некоторые недостатки, например, способ виртуализации вызовов функций ядра ОС. Featherweight Virtual Machine использует технологию «хуков», а это подразумевает под собой изменении кода ядра ОС – нечто ставшее запрещенным для 64-битных операционных систем, начиная с Vista. Подобного рода патчи кода ядра активируют Patch Guard —¬ специальный компонент ОС, вызывающий BSOD (синий «экран смерти») в таких случаях, поскольку Microsoft рассматривает патчи как злонамеренные действия. Поэтому FVM могла бы быть хорошей отправной точкой для первого взгляда на технологии виртуализации как таковые, но она недостаточно совместима с современными операционными системами. Основные трудности возникают при попытках сохранить совместимость с Patch Guard, мы детально рассмотрим эти проблемы позже.

Большая часть программистов изобретает технологии обхода Patch Guard, но подобные обходы ослабляют защиту ОС, делая её более уязвимой к «традиционным» зловредам уровня ядра, которые не могли бы быть запущены на ОС, защищенной Patch Guard. Нашей целью будет не обход или отключение Patch Guard, а сохранение совместимости с ним. Песочница должна добавить дополнительную защиту ОС, чтобы сделать её более устойчивой к атакам злоумышленников, а не ослаблять защиту, неправильно работая с Patch Guard.

В этом цикле статей мы собираемся сосредоточиться на разработке легковесной виртуальной машины. Главная идея состоит в том, чтобы перехватывать запросы ОС к критическим системным операциям и перенаправлять их к некоторого рода виртуальному хранилищу, к выделенной папке, для операций с файлами, в частности. Скажем, есть некоторое приложение, для которого мы хотим эмулировать модификации файла с именем C:\Myfile. Мы должны сделать копию этого файла в нашей виртуальной папке, скажем, C:\Sandbox\C\Myfile, и перенаправлять все операции, которые осуществляет приложение над данным файлом, к его виртуальному двойнику. То же самое касается операций над реестром и ещё некоторых системных механизмов.

Что следует виртуализировать и каким образом?

Давайте подведём итоги, что именно означает виртуализация операции. Начнём с виртуализации файловой системы. Как вы знаете, для получения доступа к файлу приложение сперва открывает его.

С точки зрения Windows, происходит вызов функции API, такой как CreateFile(). Если вызов осуществлен успешно, приложение может читать и писать в файл. Когда работа закончена, файл закрывается посредством вызова CloseHandle() API. Таким образом, мы должны иметь возможность перехватить вызов CreateFile(), изменить его параметры, такие как имя файла, которое приложение хочет открыть. Этим мы бы принудительно заставили приложение открыть другой файл.

Но перехватывать именно вызов CreateFile() – плохая идея по нескольким причинам: во-первых, приложение может открыть файл и другим образом, не только используя CreateFile(). Например, это может быть сделано посредством NtCreateFile(), нативного вызова API, которая по факту и вызывается в CreateFile(). Таким образом, перехватив NtCreateFile(), мы перехватим и CreateFile(), потому что в конечном итоге он вызывает NtCreateFile(). Но NtCreateFile() не является самой нижележащей функцией.

Так где же находится самая «нижняя» функция CreateFile(), которая будет вызываться, когда приложение хочет открыть/создать файл? Такая функция находится в коде режима ядра. Все файловые операции управляются драйверами файловых систем. Windows поддерживает так называемые фильтры файловых систем (в данной статье, мы сосредоточимся на подклассе таких фильтров — минифильтрах.), которые используются для фильтрации файловых операций. Следовательно, написав минифильтр файловой системы, мы смогли бы перехватывать все файловые системные операции, которые нам нужны. То есть наша первая цель – перехват системных вызовов файловых операций в режиме ярда путем написания драйвера минифильтра. Сделав это, мы бы смогли заставить ОС открывать абсолютно другой файл. В нашем случае, это будет виртуальный файл-двойник оригинала. Однако внимательный читатель может заметить: поскольку копирование – это весьма дорогая операция, то было бы неплохо немного оптимизировать процесс. Во-первых, можно проверить, был ли открыт файл только для чтения или нет. Если да, то нет нужды делать копию. Поскольку здесь не будет никаких изменений файла, мы можем просто предоставить доступ к исходному файлу.

Мы не будем перехватывать чтение или запись как таковые – вполне достаточно перехватывать функции режима ядра, эквивалентные (CreateFile() (OpenFile()) для того, чтобы перенаправлять всю остальную работу с файлами в нашу виртуальную папку. Однако виртуализации файловой системы недостаточно. Нам также следовало бы виртуализовать реестр и некоторые другие примитивы операционной системы. Но сейчас давайте сосредоточимся именно на виртуализации файловой системы, и начнем мы с обсуждения общих вопросов, касающихся устройства файловых систем и некоторых компонентов режима ядра.

Объекты привилегированного режима и объекты типа

Когда приложение открывает файл с помощью вызова интерфейса прикладного программирования, скажем, функцией CreateFile(), происходит много интересного: во-первых, для так называемых символических имен в заданном имени файла ищется их «родной» двойник, как показано ниже:

Например, если приложение открывает файл с названием «c:\mydocs\file.txt», его имя заменяется чем-то вроде «\Device\HarddiskVolume1\mydocs\file.txt». По сути, символическое имя «C:\» было заменено на «устройство» с именем «\Device\HarddiskVolume1». Во-вторых, в результате родное имя снова анализируется менеджером объектов — компонентом в привилегированном режиме операционной системы, чтобы определить, на какой драйвер поступал запрос на открытие. Когда драйвер регистрируется в системе, он представляется структурой DRIVER_OBJECT. Эта структура, наряду с другими вещами, содержит список устройств, за которые драйвер несет ответственность. Каждое устройство, в свою очередь, представляется структурой объекта устройства (DEVICE_OBJECT) и драйвер несет ответственность за создание объектов устройств (DEVICE_OBJECTs), которыми он собирается управлять.

Менеджер объектов проходит через один компонент за раз и пытается определить «итоговое» устройство, ответственное за данный компонент. В нашем случае, первым делом драйвер встречает компонент «\Device». В этот момент определяется объект типа для компонента. В данном случае, это каталог объектов (object directory). Я рекомендую вам скачать утилиту winobj от systinternals.com, чтобы увидеть родное дерево каталога объектов. Оно очень похоже на дерево каталогов файловой системы — содержит каталоги объектов и различные системные объекты, такие как порты ALPC, именованные каналы, события, в виде «файлов». Как только тип объекта определяется и так называемый «тип объекта» извлекается, выполняются дальнейшие действия.

А теперь я должен сказать пару слов о том, что такое «объект типа». Во время загрузки Windows регистрирует с помощью диспетчера объектов много типов объектов, таких как объект каталога, событие, мутант (также известный разработчикам пространства пользователя как «мьютекс»), устройства, драйверы и так далее. Поэтому когда драйвер создает, скажем, объект устройства – на самом деле, он создает объект типа «устройство». Объект типа «устройство», в свою очередь, является объектом типа «объект типа». Иногда для программиста проще понять вещи, когда они рассматриваются на языке программирования, а не на разговорном языке, так что давайте выразим это понятие в C++:

class object_type
{
    virtual open( .. ) = 0;
    virtual parse( .. ) = 0;
    virtual close (.. ) = 0;
    virtual delete( ... )  = 0;
      ...

};
class eventType : public object_type
{
    virtual open( .. );
    virtual parse( .. );
    virtual close (.. );
    virtual delete( ... );
};
class objectDirectoryType : public object_type
{
    virtual open( .. );
    virtual parse( .. );
    virtual close (.. );
    virtual delete( ... );
};
class deviceType : public object_type
{
    virtual open( .. );
    virtual parse( .. );
    virtual close (.. );
    virtual delete( ... );
};

Когда пользователь создает событие (event), он просто создает экземпляр типа eventType. Как вы можете заметить, эти объекты типа «объект типа» содержат множество методов: open(), parse () и т.д. Они вызываются менеджером объектов во время синтаксического разбора имени объекта, чтобы определить, какой драйвер отвечает за то или иное конкретное устройство. В нашем случае, сначала он встречает компонент «\Device», который является просто объектом типа «каталог» (directory object). Так, будет вызван метод parse() объекта типа «каталог» (objectDirectoryType), которому будет передан остаток пути в качестве параметра:

objectDirectoryType objectDirectory_t;
objectDirectory_t.parse("HarddiskVolume1\mydocs\file.txt");

Метод parse(), в свою очередь, определит, что HarddiskVolume1 является объектом типа «устройство». Драйвер, ответственный за это устройство, извлекается (в данном случае, это драйвер файловой системы, который работает с этим томом), и метод parse () объекта типа «устройство» (deviceType), в конечном счете, вызывается c «остатком» пути (например, «\mydocs\file1.txt»). Драйвер фильтра файловой системы, о котором мы будем писать в этой статье (если быть точным, экземпляр драйвера), ответственный за указанный том, увидит именно этот остаток пути в параметрах, передаваемых соответствующей процедуре обратного вызова. Драйверы файловой системы отвечают за обработку этого «остатка» пути, так что метод parse() должен сказать диспетчеру объектов, что весь путь распознан, а значит, дальнейшая обработка имени файла не требуется. На самом деле, эти члены типа «объект типа» не документированы, но важно иметь в виду их существование, чтобы понять, как ОС работает с типами объектов ядра.

Фильтры файловой системы

Фильтры файловой системы — это особый вид драйверов, которые вставляют себя в стек драйверов файловой системы так, что они могут перехватывать все запросы приложений и драйверов, расположенных над ними. Когда приложение отправляет запрос к файловой системе, например, путем вызова функции CreateFile(), специальный пакет запроса ввода-вывода (Input Output Request Packet, или IRP) строится и отправляется диспетчеру ввода-вывода. Диспетчер ввода-вывода передает запрос драйверу, который несет ответственность за обработку данного конкретного запроса. Как уже упоминалось ранее, менеджер объектов используется для анализа имени объекта, чтобы выяснить, какой драйвер отвечает за обработку данного запроса. В нашем случае IRP адресован драйверу файловой системы, но если есть фильтры (как показано на картинке ниже), они получают этот запрос первыми и они же решают, следует выполнить запрос или отклонить его, отправить вниз, драйверу (или нижнему фильтру, если он есть), обработать запрос самим или модифицировать параметры запроса и передать его на стек драйверов. Вы можете посмотреть типовые схемы фильтров драйвера на изображении ниже.

Написание драйвера фильтра является далеко не простой задачей и требует много шаблонного кода. Существует масса запросов, получаемых от различного рода драйверов файловой системы (и следовательно, фильтров). Вы должны написать обработчик (или, более конкретно, диспетчерскую процедуру (dispatch routine)) для каждого типа запроса, даже если вы не хотите делать специальную обработку того или иного отдельного запроса. Типичная диспетчерская процедура драйвера-фильтра выглядит так:

NTSTATUS
    PassThrough(
    PDEVICE_OBJECT DeviceObject,
    PIRP Irp
    )

{
    if (DeviceObject == g_LegacyPipeFilterDevice )
    {
        DEVICE_INFO* pDevInfo = (DEVICE_INFO*)DeviceObject->DeviceExtension;
        if (pDevInfo->pLowerDevice)
        {
            IoSkipCurrentIrpStackLocation(Irp);
            return IoCallDriver(pDevInfo->pLowerDevice,Irp);
        }

    }

    Irp->IoStatus.Status = STATUS_SUCCESS;
    Irp->IoStatus.Information = 0;
    IoCompleteRequest( Irp, IO_NO_INCREMENT );
    return STATUS_SUCCESS;
}

В этом примере драйвер-фильтр канала проверяет, принадлежит ли запрос устройству драйвера файловой системы канала (указатель на объект-устройство сохранен в g_LegacyPipeFilterDevice), и если да, то он передает запрос нижнему устройству, например, нижележащему фильтру, или на сам драйвер устройства. Иначе процедура просто успешно завершает запрос. Запросы ввода- вывода отправляются диспетчером ввода-вывода, о котором упоминалось выше, в форме запроса пакетов ввода-вывода или же просто пакетов IRP. Каждый IRP, наряду с большим количеством других материалов, содержит так называемые кадры стека. Чтобы упростить вещи, вы можете представлять их просто кадрами стека процедуры, которые распределяются между зарегистрированными фильтрами так, чтобы каждый фильтр имел свой собственный кадр стека.

Кадр содержит параметры процедуры, которые могут быть прочитаны или изменены. Эти параметры включают в себя входные данные для запроса, например, имя файла, который будет открыт при обработке запроса IRP_MJ_CREATE. Если мы хотим изменить некоторые значения для нижнего драйвера, мы должны вызвать IoGetNextIrpStackLocation (), чтобы получить кадр стека нижнего драйвера. Большинство драйверов просто вызывают IoSkipCurrentIrpStackLocation(): эта функция меняет указатель кадра стека внутри IRP так, что драйвер нижнего уровня получает тот же кадр, как и наш драйвер. С другой стороны, драйвер может вызвать IoCopyCurrentIrpStackLocationToNext() для копирования содержимого кадра стека в кадр нижележащего фильтра, но это более затратная процедура, и ее обычно используют, если драйвер хочет выполнить какую-то работу после обработки запроса ввода-вывода путем регистрации процедуры обратного вызова, которая называется процедурой завершения ввода-вывода.

Функция PassThrough(), приведенная выше, должна быть зарегистрирована драйвером фильтра для получения уведомления от диспетчера ввода-вывода всякий раз, когда приложения отправляют запросы, которые мы хотим перехватить. Фрагмент кода ниже показывает, как это обычно делается:

NTSTATUS RegisterLegacyFilter(PDRIVER_OBJECT DriverObject)
{

    NTSTATUS        ntStatus;
    UNICODE_STRING  ntWin32NameString;    
    PDEVICE_OBJECT  deviceObject = NULL;
    ULONG ulDeviceCharacteristics = 0;
    
    ntStatus = IoCreateDevice(
        DriverObject,                   // Наш  объект драйвера
        sizeof(DEVICE_INFO),                              
        NULL,               
        FILE_DEVICE_DISK_FILE_SYSTEM,            // Тип устройства
        ulDeviceCharacteristics,     // Характеристики устройства
        FALSE,                          // Не эксклюзивное устройство
        &deviceObject );                // ссылка на объект устройства 

    if ( !NT_SUCCESS( ntStatus ) )
    {
        return ntStatus;
    }
    
    UNICODE_STRING uniNamedPipe;
    RtlInitUnicodeString(&uniNamedPipe,L"\\Device\\NamedPipe");
    PFILE_OBJECT fo;
    PDEVICE_OBJECT pLowerDevice;
    ntStatus = IoGetDeviceObjectPointer(&uniNamedPipe,GENERIC_ALL,&fo,&pLowerDevice);
    if ( !NT_SUCCESS( ntStatus ) )
    {
        IoDeleteDevice(deviceObject);
        return ntStatus;
    }
    DEVICE_INFO* devinfo = (DEVICE_INFO*)deviceObject->DeviceExtension;
    devinfo->ul64DeviceType = DEVICETYPE_PIPE_FILTER;
    devinfo->pLowerDevice = NULL;
    g_DriverObject = DriverObject;
    g_LegacyPipeFilterDevice = deviceObject;
    
    if (FlagOn(pLowerDevice->Flags, DO_BUFFERED_IO))
    {
        SetFlag(deviceObject->Flags, DO_BUFFERED_IO);
    }

    if (FlagOn(pLowerDevice->Flags, DO_DIRECT_IO))
    {
        SetFlag(deviceObject->Flags, DO_DIRECT_IO);
    }
    if (FlagOn(pLowerDevice->Characteristics, FILE_DEVICE_SECURE_OPEN))
    {
        DbgPrint("Setting FILE_DEVICE_SECURE_OPEN on legacy filter \n");
        SetFlag(deviceObject->Characteristics, FILE_DEVICE_SECURE_OPEN);
    }

    //
    // Установить  т  обработчики. 

    for (size_t i = 0; i <= IRP_MJ_MAXIMUM_FUNCTION; i++) {

        DriverObject->MajorFunction[i] = PassThrough;
    }
    DriverObject->MajorFunction[IRP_MJ_CREATE] = CreateHandler;
    DriverObject->MajorFunction[IRP_MJ_CREATE_NAMED_PIPE] = CreateHandler;

    
    //
    //  «Подцепиться» к стеку драйверов.
    //
    //  Возможно, этот запрос  будет отменен, так как этот объект 
    //  устройства не закончил установку. Такое может произойти, если фильтр
    //  загружен сразу же, как только был примонтирован этот том. 
    //

    for (int i = 0; i < 8; ++i)
    {
        LARGE_INTEGER interval;

        ntStatus = IoAttachDeviceToDeviceStackSafe(
            deviceObject,
            pLowerDevice,
            &(devinfo->pLowerDevice));

        if (NT_SUCCESS(ntStatus))
        {
            break;
        }

        //
        //  Задержка, дающая объекту устройства шанс закончить его установку,       
//  так, чтобы мы могли попробовать снова.
        //
        interval.QuadPart = (500 * DELAY_ONE_MILLISECOND);
        KeDelayExecutionThread(KernelMode, FALSE, &interval);
    }
    
    if ( !NT_SUCCESS( ntStatus ) )
    {
        IoDeleteDevice(deviceObject);
        
        return ntStatus;
    }
    return ntStatus;
}

Приведенный выше код регистрирует устройство-фильтр для запросов, отправляемых к именованным каналам. Сначала он получает экземпляр класса DEVICE_OBJECT виртуального устройства, которое представляет каналы:

ntStatus = IoGetDeviceObjectPointer(&uniNamedPipe,GENERIC_ALL,&fo,&pLowerDevice)

Далее он заполняет массив MajorFunction обработчиком PassThrough() по умолчанию. Этот массив представляет все типы запросов, которые диспетчер ввода-вывода может отправить на устройства, контролируемые драйвером. Если вы хотите проводить обработку некоторых запросов особым образом, вы регистрируете дополнительные обработчики для них, как показано в коде на примере обработчика CreateHandler. Последний шаг – прикрепить наш фильтр к стеку драйверов:

ntStatus = IoAttachDeviceToDeviceStackSafe(
            deviceObject,
            pLowerDevice,
            &(devinfo->pLowerDevice));

Вспомним, как наша диспетчерская процедура PassThrough() передаёт запрос вниз по стеку через процедуру CallDriver(), просто передавая IRP в качестве параметра, и указатель на нижнее устройство. Данный указатель – это устройство, к которому мы прикрепились. Когда API-функция вызывает устройство, в какой-то момент она использует его имя, например, \\Device\NamedPipe, при этом она не знает ни о каких фильтрах. Но как получилось, что наш фильтр получает запрос? «Магия» осуществляется функцией IoAttachDeviceToDeviceStackSafe() – она прикрепляет наше прозрачное устройство фильтра (deviceObject), которое было создано с помощью IoCreateDevice(), к нижнему устройству, в нашем случае, к тому, которое называется \\Device\NamedPipe. С этого момента все запросы, направляемые к именованным каналам, сначала проходят через наш фильтр. Обратите внимание, что CreateIoDevice() передает NULL в качестве имени устройства. В данном случае имя не требуется, поскольку это устройство-фильтр и, следовательно, не будет никаких запросов, направляемых непосредственно к фильтру, вместо этого они пойдут устройству, но вышележащий фильтр перехватит их первым.

С этого момента мы почти закончили наш минимальный драйвер-фильтр. Все, что нам нужно сделать – это закодировать процедуру DriverEntry(), которая просто вызывает RegisterLegacyFilter:

NTSTATUS
DriverEntry (
    __in PDRIVER_OBJECT DriverObject,
    __in PUNICODE_STRING RegistryPath
    )

{

    return RegisterLegacyFilter(DriverObject);

}

 

Минифильтры файловой системы

Как вы видели в предыдущем разделе, мы написали много кода только для записи ключевых обработчиков драйвера, которые ничего не делают. Они необходимы, просто чтобы написать минимальный работающий драйвер. Чтобы упростить вещи, на сцену выходит новый тип драйверов-фильтров – драйверы минифильтров. Это плагины к «устаревшему» драйверу-фильтру FltMgr, или диспетчеру фильтров. Драйвер FltMgr является «традиционным» драйвером-фильтром, который реализует большинство шаблонного кода и позволяет разработчику создавать «полезную нагрузку» к этому драйверу в виде плагина для него. Эти плагины называются минифильтрами файловой системы. Краткий макет минифильтров показан на рисунке ниже.

Как вы помните из предыдущих глав, каждый «традиционный» фильтр присоединяется к стеку драйверов конкретного устройства. Так было бы, впрочем, неудобно контролировать занимаемое фильтром точное место в стеке. Минифильтры исправляют эту проблему путем введения двух новых концепций – это высота и фрейм. Высота помогает контролировать порядок, в котором вы будете получать уведомления от диспетчера ввода-вывода. Например, на картинке выше минифильтр А является первым в очереди на получение IRP, минифильтр Б – вторым и так далее. В общем, чем выше ваш драйвер, тем выше место, которое он занимает в стеке. Диапазоны высот группируются во фреймы. Каждый фрейм представляет позицию экземпляра FltMgr в качестве «традиционного» фильтра в стеке драйверов. Например, на картинке выше есть два экземпляра FltMgr под названиями «Фрейм 1» и «Фрейм 0». Как видите, есть и другие «традиционные» фильтры, присутствующие в стеке вместе с экземплярами FltMgr. Драйвер определяет его высоту в .inf-файле, специальном типе установочного файла ОС, который используется для установки драйверов.

Заключение

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

В следующей статье мы рассмотрим проблему виртуализации файловой системы с практической стороны и приступим к реализации инструмента «песочница», который виртуализирует работу с файлами. Любая коммерческая «песочница», однако, виртуализирует не только операции с файловой системой, но и множество других механизмов системы, таких как системный реестр, удаленный вызов процедур, именованные каналы и т.д.

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *