Как написать свою «песочницу»: пример простейшей «песочницы». Часть II
В первой части статьи вы получили краткое представление о драйверах в привилегированном режиме. Настало время покопаться в нашей песочнице.
Пример песочницы: сборка и установка драйвера
Ядром нашей песочницы является драйвер мини-фильтр. Вы можете найти его исходный код в src\FSSDK\Kernel\minilt. Я предполагаю, что вы используете комплект WDK 7.x для сборки драйвера. Чтобы сделать это, вы должны запустить соответствующее окружение, скажем, Win 7×86 checked, и перейти в каталог с исходным кодом. Просто напишите «build /c» в командной строке при запуске в среде разработки и вы получите построенные драйверы. Для установки драйвера достаточно скопировать файл *.inf в папку, содержащую файл *.sys, перейти в этот каталог с помощью проводника и использовать контекстное меню на файле *.inf, где выбрать пункт «Установить», и драйвер будет установлен. Я рекомендую вам проводить все эксперименты внутри виртуальной машины, хорошим выбором станет VMware. Обратите внимание, что 64-битные варианты Windows не будут загружать неподписанные драйверы. Чтобы иметь возможность запускать драйвер в VMware, вы должны включить отладчик привилегированного режима в гостевой ОС. Это может быть сделано посредством выполнения следующих команд в cmd, запущенных от имени администратора:
1. bcdedit /debug on
2. bcdedit /bootdebug on
Теперь вы должны назначить именованный канал в качестве последовательного порта для VMware и настройки с помощью WinDBG, установленного на вашей машине. После этого вы сможете подключаться к VMware с дебаггером и отладить свои драйверы.
Найти подробную информацию по настройке VMware для отладки драйверов можно в статье по ссылке ниже:
silverstr.ufies.org/lotr0/windbg-vmware.html
Пример песочницы: обзор архитектуры
Наша простая песочница состоит из трех модулей:
• драйвера привилегированного режима, который реализует примитивы виртуализации;
• службы пользовательского режима, которая получает сообщения от драйвера и может изменить поведение файловой системы путем изменения параметров полученных уведомлений от драйвера;
• промежуточной библиотеки fsproxy, помогающей службе общаться с драйвером.
Давайте начнем рассмотрение нашей простейшей песочницы с драйвера в привилегированном режиме.
Пример песочницы: пишем драйвер
В то время как обычные приложения, как правило, начинают свое выполнение в WinMain (), драйверы делают это с функции DriverEntry (). Давайте начнем изучение драйвера с этой функции.
NTSTATUS
DriverEntry (
__in PDRIVER_OBJECT DriverObject,
__in PUNICODE_STRING RegistryPath
)
{
OBJECT_ATTRIBUTES oa;
UNICODE_STRING uniString;
PSECURITY_DESCRIPTOR sd;
NTSTATUS status;
UNREFERENCED_PARAMETER( RegistryPath );
ProcessNameOffset = GetProcessNameOffset();
DbgPrint("Loading driver");
//
// Зарегистрироваться через менеджер фильтров
//
status = FltRegisterFilter( DriverObject,
&FilterRegistration,
&MfltData.Filter );
if (!NT_SUCCESS( status ))
{
DbgPrint("RegisterFilter failure 0x%x \n",status);
return status;
}
//
// Создать порт связи.
//
RtlInitUnicodeString( &uniString, ScannerPortName );
//
// Мы защищаем порт, так чтобы только администратор и система имели к нему доступ.
//
status = FltBuildDefaultSecurityDescriptor( &sd, FLT_PORT_ALL_ACCESS );
if (NT_SUCCESS( status )) {
InitializeObjectAttributes( &oa,
&uniString,
OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE,
NULL,
sd );
status = FltCreateCommunicationPort( MfltData.Filter,
&MfltData.ServerPort,
&oa,
NULL,
FSPortConnect,
FSPortDisconnect,
NULL,
1 );
//
// Освобождаем дескриптор безопасности во всех случаях. Он не нужен // после того, как вызвана FltCreateCommunicationPort()
//
FltFreeSecurityDescriptor( sd );
regCookie.QuadPart = 0;
if (NT_SUCCESS( status )) {
//
// Начинаем фильтрацию запросов ввода-вывода.
//
DbgPrint(" Starting Filtering \n");
status = FltStartFiltering( MfltData.Filter );
if (NT_SUCCESS(status))
{
status = PsSetCreateProcessNotifyRoutine(CreateProcessNotify,FALSE);
if (NT_SUCCESS(status))
{
DbgPrint(" All done! \n");
return STATUS_SUCCESS;
}
}
DbgPrint(" Something went wrong \n");
FltCloseCommunicationPort( MfltData.ServerPort );
}
}
FltUnregisterFilter( MfltData.Filter );
return status;
}
DriverEntry имеет несколько ключевых особенностей. Во-первых, эта функция регистрирует драйвер как minifler функцией FltRegisterFilter ():
status = FltRegisterFilter( DriverObject,
&FilterRegistration,
&MfltData.Filter );
Он представляет собой массив указателей для обработчиков определенных операций, которые хочет перехватывать в FilterRegistration, и получает экземпляр фильтра в MfltData.Filter в случае успешной регистрации. FilterRegistration объявляется следующим образом:
const FLT_REGISTRATION FilterRegistration = {
sizeof( FLT_REGISTRATION ), // Размер
FLT_REGISTRATION_VERSION, // Версия
0, // Маркеры
NULL, // Регистрация контекста
Callbacks, // обработчики
DriverUnload, // выгрузка фильтра
FSInstanceSetup, // Установка экземпляра фильтра
FSQueryTeardown, // Запрос на деинсталляцию экземпляра
NULL,
NULL,
FSGenerateFileNameCallback, // Создание имени файла
FSNormalizeNameComponentCallback, // Нормализация компонента имени
NULL, // Нормализация очистки контекста
#if FLT_MGR_LONGHORN
NULL, // Оповещение о транзакциях
FSNormalizeNameComponentExCallback, // Нормализация только компонента имени
#endif // FLT_MGR_LONGHORN
};
Как видите, структура содержит указатель на массив обработчиков событий (Callbacks). Это — аналог диспетчерских процедур в «устаревших» драйверах. Кроме того, эта структура содержит указатели на некоторые другие вспомогательные функции — их мы опишем позже. Сейчас остановимся на обработчиках, описанных в массиве Callbacks. Они определяются следующим образом:
const FLT_OPERATION_REGISTRATION Callbacks[] = {
{ IRP_MJ_CREATE,
0,
FSPreCreate,
NULL
},
{ IRP_MJ_CLEANUP,
0,
FSPreCleanup,
NULL},
{ IRP_MJ_OPERATION_END}
};
Вы можете посмотреть подробное описание структуры FLT_OPERATION_REGISTRATION в MSDN. Наш драйвер регистрирует только два обработчика — FSPreCreate, который будет вызываться каждый раз, когда получен запрос IRP_MJ_CREATE, и FSPreCleanup, который, в свою очередь, будет вызываться каждый раз, когда получен IRP_MJ_CLEANUP. Этот запрос поступит, когда закроется последний дескриптор файла. Мы можем (и будем) изменять входные параметры и отправим измененный запрос вниз по стеку, так что нижние фильтры и драйвер файловой системы будут получать измененный запрос. Мы могли бы зарегистрировать так называемые пост-уведомления, приходящие по завершению операции. Для этого нулевой указатель, который следует за указателем на FSPreCreate, может быть заменен указателем на соответствующий пост-обработчик. Мы должны завершить наш массив элементом IRP_MJ_OPERATION_END. Это «поддельная» операция, знаменующая собой конец массива обработчиков событий. Обратите внимание, что мы не должны предоставлять обработчик для каждой операции IRP_MJ_XXX, как нам пришлось бы сделать для «традиционных» драйверов-фильтров.
Вторая важная вещь, которую выполняет наш DriverEntry () — это создание порта мини-фильтров. Он используется для отправки уведомлений из службы уровня пользователя и получает ответы от него. Делается это с помощью операции FltCreateCommunicationPort ():
status = FltCreateCommunicationPort( MfltData.Filter,
&MfltData.ServerPort,
&oa,
NULL,
FSPortConnect,
FSPortDisconnect,
NULL,
1 );
Указатели на функции FSPortConnect () и FSPortDisconnect () возникают соответственно при подключении и отключении службы пользовательского режима от драйвера.
И последнее, что нужно сделать — это запустить фильтрацию:
status = FltStartFiltering( MfltData.Filter );
Обратите внимание, что указатель на экземпляр фильтра, возвращенный FltRegisterFilter (), передается в эту процедуру. С этого момента мы начнем получать уведомления о запросах IRP_MJ_CREATE & IRP_MJ_CLEANUP. Вместе с уведомлениями о фильтрации файлов также просим ОС сообщить нам, когда новый процесс загружается и выгружается с помощью этой функции:
PsSetCreateProcessNotifyRoutine(CreateProcessNotify,FALSE);
CreateProcessNotify — это наш обработчик уведомлений о создании и завершении процесса.
Пример песочницы: обработчик FSPreCreate
Настоящая магия рождается здесь. Суть данной функции заключается в том, чтобы сообщить, какой файл открыт и каким процессом было инициировано открытие. Эти данные отправляются в службу пользовательского режима. Служба (сервис) предоставляет ответ в виде команды либо о запрете доступа к файлу, либо о перенаправлении запроса на другой файл (именно так на самом деле работает песочница), либо о разрешении на выполнение операции. Первое, что происходит в данном случае — проверка соединения со службой пользовательского режима через порт связи (коммуникационный порт), который мы создали в DriverEntry (), и, в случае отсутствия соединения, никакого дальнейшего действия не произойдет. Мы также проверяем, является ли служба (сервис) источником (инициатором) запроса — мы делаем это путем проверки поля UserProcess глобально выделенной структуры MfltData. Это поле заполняется в подпрограмме PortConnect (), которая вызывается, когда служба (сервис) пользовательского режима подключается к порту. Также мы не хотим иметь дело с запросами, связанными с подкачкой страниц. Во всех этих случаях мы возвращаем код возврата FLT_PREOP_SUCCESS_NO_CALLBACK, означающий, что мы завершили обработку запроса и у нас нет постоперационного обработчика. В противном случае, мы вернем FLT_PREOP_SUCCESS_WITH_CALLBACK. Если бы это был «традиционный» драйвер-фильтр, то нам бы пришлось иметь дело с кадрами стека, о которых я упоминал ранее, процедурой IoCallDriver и т.д. В случае мини-фильтров передача запроса довольно проста.
Если мы хотим обработать запрос, то первое, что нам необходимо сделать, это заполнить структуру, которую мы хотим передать в пользовательский режим — MINFILTER_NOTIFICATION. Она полностью кастомизируема. Мы передаем тип операции (CREATE), имя файла, над которым выполнялся запрос, идентификационный номер (PID) процесса и имя исходного процесса. Стоит обратить внимание на то, как мы выясняем имя процесса. На самом деле, это недокументированный способ получения имени процесса, который не рекомендуют использовать в коммерческом программном обеспечении. Более того, это не работает с x64-версиями Windows. В коммерческом ПО вы передадите только ID (идентификационный номер) процесса в пользовательский режим, и если вам нужно исполняемое имя, вы можете получить его с помощью API пользовательского режима. Вы, к примеру, можете использовать API OpenProcess (), чтобы открыть процесс по его ID и затем вызвать API GetProcessImageFileName (), чтобы получить имя исполняемого файла. Но чтобы упростить нашу песочницу, мы получаем имя процесса из недокументированного поля структуры PEPROCESS. Чтобы выяснить смещение имени (относительный адрес), мы учитываем, что в системе есть процесс под названием «SYSTEM». Мы сканируем процесс, содержащий данное имя в структуре PEPROCESS, затем используем обнаруженное смещение имени при анализе какого-либо другого процесса. Для получения более подробной информации — см. функцию SetProcessName ().
Мы получаем имя файла из «целевого» файла, для которого был получен запрос (например, запрос об открытии файла) с помощью двух функций — FltGetFileNameInformation () и FltParseFileNameInformation ().
После заполнения структуры MINFILTER_NOTIFICATION мы отправляем ее в пользовательский режим:
Status = FltSendMessage( MfltData.Filter,
&MfltData.ClientPort,
notification,
sizeof(MINFILTER_NOTIFICATION),
&reply,
&replyLength,
NULL );
И получаем ответ в переменной reply. Если нас просят отменить операцию, то действие простое:
if (!reply.bAllow)
{
Data->IoStatus.Status = STATUS_ACCESS_DENIED;
Data->IoStatus.Information = 0;
return FLT_PREOP_COMPLETE;
}
Ключевыми моментами здесь являются следующие: во-первых, мы меняем код возврата, возвращая FLT_PREOP_COMPLETE. Это значит, что мы не будем передавать запрос вниз по стеку драйверов, как, например, мы сделали бы при вызове IoCompleteRequest () из «традиционного» драйвера без вызова IoCallDriver (). Во-вторых, мы заполняем поле IoStatus в структуре запроса. Устанавливаем код ошибки STATUS_ACCESS_DENIED и поле Information «в ноль». Как правило, в поле Information записывается количество байтов, переданных во время операции, например, при операции копирования записывается количество скопированных байтов.
Если мы хотим перенаправить операцию, то выглядит это по-другому:
if (reply.bSupersedeFile)
{
// извлечь имя тома
// возможный формат файла: \Device\HardDiskVolume1\Windows\File,
// или \DosDevices\C:\Windows\File OR \??\C:\Windows\File или C:\Windows\File
RtlZeroMemory(wszTemp,MAX_STRING*sizeof(WCHAR));
// \Device\HardDiskvol\file или \DosDevice\C:\file
int endIndex = 0;
int nSlash = 0; // количество найденных слешей
int len = wcslen(reply.wsFileName);
while (nSlash < 3 )
{
if (endIndex == len ) break;
if (reply.wsFileName[endIndex]==L'\\') nSlash++;
endIndex++;
}
endIndex--;
if (nSlash != 3) return FLT_PREOP_SUCCESS_NO_CALLBACK; // ошибка в имени файла
WCHAR savedch = reply.wsFileName[endIndex];
reply.wsFileName[endIndex] = UNICODE_NULL;
RtlInitUnicodeString(&uniFileName,reply.wsFileName);
HANDLE h;
PFILE_OBJECT pFileObject;
reply.wsFileName[endIndex] = savedch;
NTSTATUS Status = RtlStringCchCopyW(wszTemp,MAX_STRING,reply.wsFileName + endIndex );
RtlInitUnicodeString(&uniFileName,wszTemp);
Status = IoReplaceFileObjectName(Data->Iopb->TargetFileObject, reply.wsFileName, wcslen(reply.wsFileName)*sizeof(wchar_t));
Data->IoStatus.Status = STATUS_REPARSE;
Data->IoStatus.Information = IO_REPARSE;
FltSetCallbackDataDirty(Data);
return FLT_PREOP_COMPLETE;
}
Ключевым здесь является вызов IoReplaceFileObjectName
Status = IoReplaceFileObjectName(Data->Iopb->TargetFileObject, reply.wsFileName, wcslen(reply.wsFileName)*sizeof(wchar_t));
Данная функция изменяет имя файла переданного файлового объекта (FILE_OBJECT) — объекта диспетчера ввода-вывода, который представляет собой открытый файл. Вручную имя заменяется следующим образом: освободив память с полем, содержащим имя, мы выделяем буфер и копируем новое имя туда. Но с момента появления функции IoReplaceFileObjectName в Windows 7, её настоятельно рекомендуют использовать вместо буфера. В личном проекте автора (продукт Cybergenic Shade Sandbox), который совместим со всеми операционными системами — от XP до Windows 10, я как раз вожусь с буферами вручную, если драйвер работает на устаревших ОС (до Win 7). После того, как имя файла изменено, мы заполняем данные специальным статусом STATUS_REPARSE, а поле Information заполняем значением IO_REPARSE. Дальше мы возвращаем статус FLT_PREOP_COMPLETE. REPARSE означает, что мы хотим, чтобы диспетчер ввода-вывода перезапустил исходный запрос (с новыми параметрами), как в случае, когда приложение (инициатор запроса) первоначально запросило бы открыть файл с новым именем. Также мы должны вызвать FltSetCallbackDataDirty () — эта API-функция нужна каждый раз, когда мы меняем структуру данных, кроме тех случаев, когда мы также меняем IoStatus. На самом деле, мы действительно здесь меняем IoStatus, поэтому вызываем данную функцию, чтобы убедиться в том, что уведомили диспетчера ввода-вывода об этих изменениях.
Пример песочницы: провайдеры имен
Поскольку мы изменяем имена файлов, наш драйвер должен содержать реализацию обработчиков провайдера имен, вызываемые при запрашивании имени файла или когда имя файла «нормализуется». Этими обработчиками являются FSGenerateFileNameCallback и FSNormalizeNameComponentCallback (Ex).
Наш метод виртуализации основан на «перезапуске» запроса IRP_MJ_CREATE (мы делаем вид, что виртуализированные имена — REPARSE_POINTS), и реализация этих обработчиков является достаточно простым делом, которое подробно описано здесь: fsfilters.blogspot.ru/2011/03/names-in-minifilters-implementing-name.html.
Служба пользовательского режима
Режим пользователя находится в проекте filewall (см. исходный код к статье) и общается с драйвером. Ключевая функциональность представляется следующей функцией:
bool CService::FS_Emulate( MINFILTER_NOTIFICATION* pNotification,
MINFILTER_REPLY* pReply,
const CRule& rule)
{
using namespace std;
// сформировать новый путь
// проверить, существует ли путь, если нет - создать/копировать
if(IsSandboxedFile(ToDos(pNotificationwsFileName).c_str(),rule.SandBoxRoot))
{
pReply->bSupersedeFile = FALSE;
pReply->bAllow = TRUE;
return true;
}
wchar_t* originalPath = pNotification->wsFileName; // неуправляемый код
int iLen = GetNativeDeviceNameLen(originalPath);
wstring relativePath;
for (int i = iLen ; i < wcslen(originalPath); i++) relativePath += originalPath[i];
wstring substitutedPath = ToNative(rule.SandBoxRoot) + relativePath;
if (PathFileExists(ToDos(originalPath).c_str()))
{
if (PathIsDirectory(ToDos(originalPath).c_str()) )
{
// пустая директория – создаем ее в песочнице
CreateComplexDirectory(ToDos(substitutedPath).c_str() );
}
else
{
// полное имя файла – создать копию файла в песочнице (sandbox), если ее еще нет
wstring path = ToDos(substitutedPath);
wchar_t* pFileName = PathFindFileName(path.c_str());
int iFilePos = pFileName - path.c_str();
wstring Dir;
for (int i = 0; i< iFilePos-1; i++) Dir = Dir + path[i];
CreateComplexDirectory(ToDos(Dir).c_str());
CopyFile(ToDos(originalPath).c_str(),path.c_str(),TRUE);
}
}
else
{
// нет такого файла, но родительский каталог создать надо, если его нет
wstring path = ToDos(substitutedPath);
wchar_t* pFileName = PathFindFileName(path.c_str());
int iFilePos = pFileName - path.c_str();
wstring Dir;
for (int i = 0; i< iFilePos-1; i++) Dir = Dir + path[i];
CreateComplexDirectory(ToDos(Dir).c_str());
}
wcscpy(pReply->wsFileName,substitutedPath.c_str());
pReply->bSupersedeFile = TRUE;
pReply->bAllow = TRUE;
return true;
}
Она вызывается, когда драйвер решает перенаправить имя файла. Алгоритм здесь очень простой: если файл, помещенный в песочницу, уже существует, то запрос просто перенаправляется, заполняя переменную pReply новым именем файла — именем в папке песочницы. Если такой файл не существует, то копируется исходный файл и только после этого изменяется исходный запрос, чтобы указать на новый скопированный файл. Как служба узнает, что запрос необходимо перенаправить для конкретного процесса? Это делается с помощью правил — см. реализацию класса CRule. Правила (обычно единственное правило в нашей демо-службе) загружаются функцией LoadRules ().
bool CService::LoadRules()
{
CRule rule;
ZeroMemory(&rule, sizeof(rule));
rule.dwAction = emulate;
wcscpy(rule.ImageName,L"cmd.exe");
rule.GenericNotification.iComponent = COM_FILE;
rule.GenericNotification.Operation = CREATE;
wcscpy(rule.GenericNotification.wsFileName,L"\\Device\\Harddisk*\\*.txt");
wcscpy(rule.SandBoxRoot,L"C:\\Sandbox");
GetRuleManager()->AddRule(rule);
return true;
}
Эта функция создает правило для процесса (или процессов) под названием «cmd.exe» и перенаправляет в песочницу все операции с файлами *.txt. Если вы запустите cmd.exe на ПК, где запущена наша служба, она изолирует ваши операции в песочницу. Например, вы можете создать txt-файл из cmd.exe, скажем, запустив команду «dir > files.txt», files.txt будет создан в C:/sandbox//files.txt, где — текущий каталог для cmd.exe. Если вы добавите уже существующий файл из cmd.exe, вы получите две его копии — неизменённую версию в исходной файловой системе и измененную в C:/Sandbox.
Заключение
В этой статье мы разобрали основные аспекты создания песочницы. Однако некоторые детали и проблемы остались незатронутыми.
Например, нельзя управлять правилами из пользовательского режима, так как это существенно замедляет работу ПК. Данный подход является достаточно простым в плане реализации и возможен для использования в учебных целях, но ни в коем случае не должен использоваться в коммерческом ПО.
Еще одно ограничение — это структура уведомлений/ответов с предварительно определёнными буферами для файловых имен. Эти буферы имеют два недостатка: во-первых, их размер ограничен и некоторые файлы, находящиеся глубоко в файловой системе, будут обрабатываться неправильно. Во-вторых, существенная часть памяти режима ядра, выделенная под имя файла, в большинстве случаев не используется. Таким образом, следует применять более разумную стратегию выделения памяти в коммерческом ПО.
И еще один недостаток — повсеместное использование функции FltSendMessage (), которая является довольно медленной. Ее следует применять исключительно тогда, когда приложение пользовательского режима должно показать пользователю запрос, а пользователь — разрешить или отклонить операцию. В этом случае данную функцию использовать можно, так как взаимодействие с человеком гораздо медленнее, чем выполнение какого-либо кода. Но если программа реагирует автоматически, вы должны избегать чрезмерного взаимодействия с кодом пользовательского режима.
Ссылки на источники
Оригинальная статья
Исходный код рассматриваемой песочницы