Привет, Хабр. Представляю вам гайд по NTFS Reparse points (далее RP), точкам повторной обработки. Это статья для тех, кто только начинает изучать тонкости разработки ядра Windows и его окружения. В начале объясню теорию с примерами, потом дам интересную задачку.



RP является одной из ключевых фич файловой системы NTFS, которая бывает полезна в решении задач резервного копирования и восстановления. Как следствие, Acronis очень заинтересован в данной технологии.

Полезные ссылки


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


Теория


Точка повторной обработки или Reparse Point (RP) — это объект заданного размера с данными, определенными программистом, и уникальным тегом. Пользовательский объект представлен структурой REPARSE_GUID_DATA_BUFFER.

typedef struct _REPARSE_GUID_DATA_BUFFER {
  ULONG  ReparseTag;
  USHORT ReparseDataLength;
  USHORT Reserved;
  GUID   ReparseGuid;
  struct {
    UCHAR DataBuffer[1];
  } GenericReparseBuffer;
} REPARSE_GUID_DATA_BUFFER, *PREPARSE_GUID_DATA_BUFFER;

Размер блока данных RP доходит до 16 килобайт.
ReparseTag — 32-х разрядный тэг.
ReparseDataLength — размер данных
DataBuffer — указатель на пользовательские данные

RP, предоставленные Microsoft, могут быть представлены структурой REPARSE_DATA_BUFFER. Не следует её использовать для самописных RP.

Рассмотрим формат тега:



M — Бит Майкрософт; Если этот бит установлен, значит тег разработан компанией Майкрософт.
L — Бит задержки; Если этот бит установлен, значит данные, на которые ссылается RP расположены на носителе с низкой скоростью реакции и большой задержкой выдачи данных.
R — Зарезервированный бит;
N — Бит замены имени; Если этот бит установлен, значит файл или каталог представляет другую именованную сущность в файловой системе.
Значение тега — Запрашивается у Microsoft;

Каждый раз, когда приложение создает или удаляет RP, NTFS обновляет файл метаданных \\$Extend\\$Reparse. Именно в этом файле хранятся записи о RP. Такое централизованное хранение позволяет сортировать и эффективно искать необходимый объект любому приложению.

Через механизм RP в Windows реализована поддержка символьных ссылок, системы удаленного хранилища, а также точек монтирования томов и каталогов.

Жесткие ссылки в системе Windows не являются фактическим объектом, а просто синонимом на один и тот же файл на диске. Это не отдельные объекты файловой системы, а просто еще одно наименование файла в таблице расположения файлов. Этим жёсткие ссылки отличаются от символьных.

Для использования технологии RP нам нужно написать:

  • Небольшое приложение с привилегиями SE_BACKUP_NAME или SE_RESTORE_NAME, которое будет создавать файл содержащий структуру RP, устанавливать обязательное поле ReparseTag и заполнять DataBuffer
  • Драйвер режима ядра, который будет читать данные буфера и обрабатывать обращения к этому файлу.

Создадим свой файл с RP


1. Получаем необходимые привилегии

void GetPrivilege(LPCTSTR priv)
{
	HANDLE hToken;
	TOKEN_PRIVILEGES tp;
	OpenProcessToken(GetCurrentProcess(),
		TOKEN_ADJUST_PRIVILEGES, &hToken);
	LookupPrivilegeValue(NULL, priv, &tp.Privileges[0].Luid);
	tp.PrivilegeCount = 1;
	tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
	AdjustTokenPrivileges(hToken, FALSE, &tp,
		sizeof(TOKEN_PRIVILEGES), NULL, NULL);
	CloseHandle(hToken);
}

GetPrivilege(SE_BACKUP_NAME);
GetPrivilege(SE_RESTORE_NAME);
GetPrivilege(SE_CREATE_SYMBOLIC_LINK_NAME);

2. Подготавливаем структуру REPARSE_GUID_DATA_BUFFER. Для примера в данные RP мы напишем простую строку “My reparse data”.

TCHAR data[] = _T("My reparse data");
BYTE reparseBuffer[sizeof(REPARSE_GUID_DATA_BUFFER) + sizeof(data)];
PREPARSE_GUID_DATA_BUFFER rd = (PREPARSE_GUID_DATA_BUFFER) reparseBuffer;

ZeroMemory(reparseBuffer, sizeof(REPARSE_GUID_DATA_BUFFER) + sizeof(data));

// {07A869CB-F647-451F-840D-964A3AF8C0B6}
static const GUID my_guid = { 0x7a869cb, 0xf647, 0x451f, { 0x84, 0xd, 0x96, 0x4a, 0x3a, 0xf8, 0xc0, 0xb6 }};

rd->ReparseTag = 0xFF00;
rd->ReparseDataLength = sizeof(data);
rd->Reserved = 0;
rd->ReparseGuid = my_guid;
memcpy(rd->GenericReparseBuffer.DataBuffer, &data, sizeof(data));

3. Создаем файл.

LPCTSTR name = _T("TestReparseFile");

_tprintf(_T("Creating empty file\n"));
HANDLE hFile = CreateFile(name,
	GENERIC_READ | GENERIC_WRITE, 
	0, NULL,
	CREATE_NEW, 
	FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS,
	NULL);
if (INVALID_HANDLE_VALUE == hFile)
{
_tprintf(_T("Failed to create file\n"));
	return -1;
}

4. Заполняем файл нашей структурой, используя метод DeviceIoControl с параметром FSCTL_SET_REPARSE_POINT.

_tprintf(_T("Creating reparse\n"));
if (!DeviceIoControl(hFile, FSCTL_SET_REPARSE_POINT, rd, rd->ReparseDataLength + REPARSE_GUID_DATA_BUFFER_HEADER_SIZE, NULL, 0, &dwLen, NULL))
{
	CloseHandle(hFile);
	DeleteFile(name);

	_tprintf(_T("Failed to create reparse\n"));
	return -1;
}

CloseHandle(hFile);

Полный код этого приложения можно найти тут.

После сборки и запуска у нас образуется файл. Утилита fsutil поможет посмотреть на созданный нами файл и убедиться, что наши данные на месте.



Обработка RP


Пришло время взглянуть на этот файл со стороны ядреного пространства. Я не буду углубляться в детали устройства мини-фильтра драйвера. Хорошее объяснение есть официальной документации от Microsoft с примерами кода. А мы посмотрим на post callback метод.

Нужно перезапросить IRP c параметром FILE_OPEN_REPARSE_POINT. Для этого мы вызовем FltReissueSynchronousIo. Данная функция повторит запрос, но уже с обновленными Create.Options.

Внутри структуры PFLT_CALLBACK_DATA есть поле TagData. Если вызвать метод FltFsControlFile с кодом FSCTL_GET_REPARSE_POINT, то получим наш буфер с данными.

// todo конечно стоит проверить наш ли это тэг, а не только его наличие
if (Data->TagData != NULL) 
{
if ((Data->Iopb->Parameters.Create.Options & FILE_OPEN_REPARSE_POINT) != FILE_OPEN_REPARSE_POINT)
    {
      Data->Iopb->Parameters.Create.Options |= FILE_OPEN_REPARSE_POINT;

      FltSetCallbackDataDirty(Data);
      FltReissueSynchronousIo(FltObjects->Instance, Data);
    }

    status = FltFsControlFile(
      FltObjects->Instance, 
      FltObjects->FileObject, 
      FSCTL_GET_REPARSE_POINT, 
      NULL, 
      0, 
      reparseData,
      reparseDataLength,
      NULL
    );
}



Далее можно использовать эти данные в зависимости от задачи. Можно вновь перезапросить IRP. Или инициировать совершенно новый запрос. К примеру, в проекте LazyCopy в данных RP хранится путь до оригинального файла. Автор не начинает копирование в момент открытия файла, а лишь пересохраняет данные из RP в stream context данного файла. Данные начинают копироваться в момент чтения или записи файла. Вот наиболее важные моменты его проекта:

// Operations.c - PostCreateOperationCallback

NT_IF_FAIL_LEAVE(LcGetReparsePointData(FltObjects, &fileSize, &remotePath, &useCustomHandler));

NT_IF_FAIL_LEAVE(LcFindOrCreateStreamContext(Data, TRUE, &fileSize, &remotePath, useCustomHandler, &streamContext, &contextCreated));

// Operations.c - PreReadWriteOperationCallback

status = LcGetStreamContext(Data, &context);

NT_IF_FAIL_LEAVE(LcGetFileLock(&nameInfo->Name, &fileLockEvent));

NT_IF_FAIL_LEAVE(LcFetchRemoteFile(FltObjects, &context->RemoteFilePath, &nameInfo->Name, context->UseCustomHandler, &bytesFetched));

NT_IF_FAIL_LEAVE(LcUntagFile(FltObjects, &nameInfo->Name));
NT_IF_FAIL_LEAVE(FltDeleteStreamContext(FltObjects->Instance, FltObjects->FileObject, NULL));

На самом деле, RP имеют широкий спектр применения, и открывают множество возможностей для решения различных задач. Один из них разберем на решении следующей задачи.

Задачка


Игрушка Half-life умеет запускаться в двух режимах: software mode и hardware mode, которые отличаются способом отрисовки графики в игре. Немного поковыряв игрушку в IDA Pro, можно заметить, что режимы различаются загружаемой методом LoadLibrary библиотекой: sw.dll или hw.dll.



В зависимости от входных аргументов (например “-soft”) выбирается та или иная строка и подбрасывается в вызов функции LoadLibrary.



Суть задачи заключается в том, чтобы запретить игрушке загружаться в software mode, да так, чтобы пользователь этого не заметил. В идеале чтобы пользователь даже не понял, что вне зависимости от его выбора, он загружается в hardware mode.

Конечно, можно было бы пропатчить исполняемый файл, или заменить dll файл, да даже просто скопировать hw.dll и переименовать копию в sw.dll, но мы не ищем лёгких путей. Плюс, если игру будут обновлять или переустанавливать, эффект пропадет.

Решение


Я предлагаю следующее решение: напишем небольшой мини-фильтр драйвер. Он будет работать постоянно и на него не будут влиять переустановка и обновление игры. Зарегистрируем драйвер на операцию IRP_MJ_CREATE, ведь каждый раз, когда исполняемый файл вызывает LoadLibrary, он по сути открывает файл библиотеки. Как только мы заметим, что процесс игры пытается открыть библиотеку sw.dll, мы вернем статус STATUS_REPARSE и попросим повторить запрос, но уже на открытие hw.dll. Результат: открылась нужная нам библиотека, хоть user space и просил у нас другую.

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

NT_IF_FAIL_LEAVE(PsSetCreateProcessNotifyRoutine(IMCreateProcessNotifyRoutine, FALSE));

В этом методе мы должны получить имя запускаемого файла. Для этого можно воспользоваться ZwQueryInformationProcess.

NT_IF_FAIL_LEAVE(PsLookupProcessByProcessId(ProcessId, &eProcess));

NT_IF_FAIL_LEAVE(ObOpenObjectByPointer(eProcess, OBJ_KERNEL_HANDLE, NULL, 0, 0, KernelMode, &hProcess));

NT_IF_FAIL_LEAVE(ZwQueryInformationProcess(hProcess,
                                               ProcessImageFileName,
                                               buffer,
                                               returnedLength,
                                               &returnedLength));

Если он совпадает с искомым, в нашем случае hl.exe, то необходимо запомнить его PID.

target = &Globals.TargetProcessInfo[i];
if (RtlCompareUnicodeString(&processNameInfo->Name, &target->TargetName, TRUE) == 0)
{
      target->NameInfo = processNameInfo;
      target->isActive = TRUE;
      target->ProcessId = ProcessId;

      LOG(("[IM] Found process creation: %wZ\n", &processNameInfo->Name));
}

Итак, теперь у нас в глобальном объекте сохранен PID процесса нашей игры. Можно переходить к pre create callback. Там мы должны получить имя открываемого файла. В этом нам поможет FltGetFileNameInformation. Данную функцию нельзя вызывать на DPC уровне прерываний (читай подробнее про IRQL), однако, мы собираемся делать вызов исключительно на pre create, что гарантирует нам уровень не выше APC.

status = FltGetFileNameInformation(Data, FLT_FILE_NAME_OPENED | FLT_FILE_NAME_QUERY_FILESYSTEM_ONLY | FLT_FILE_NAME_ALLOW_QUERY_ON_REPARSE, &fileNameInfo);

Далее все просто, если наше имя sw.dll, то нужно заменить его в FileObject на hw.dll. И вернуть статус STATUS_REPARSE.

// may be it is sw
if (RtlCompareUnicodeString(&FileNameInfo->Name, &strSw, TRUE) == 0)
{
// concat
NT_IF_FAIL_LEAVE(IMConcatStrings(&replacement, &FileNameInfo->ParentDir, &strHw));

// then need to change
NT_IF_FAIL_LEAVE(IoReplaceFileObjectName(FileObject, replacement.Buffer, replacement.Length));
}

Data->IoStatus.Status = STATUS_REPARSE;
Data->IoStatus.Information = IO_REPARSE;
return FLT_PREOP_COMPLETE;

Безусловно, реализация проекта целиком несколько более комплексная, но основные моменты я постарался раскрыть. Весь проект с подробностями тут.

Тестируем


Чтобы упростить наши тестовые запуски, вместо игры будем запускать небольшое приложение и библиотеки следующего содержания:

// testapp.exe
#include "TestHeader.h"

int main()
{
	TestFunction();
	return 0;
}

// testdll0.dll
#include "../include/TestHeader.h"
#include <iostream>

// This is an example of an exported function.
int TestFunction()
{
std::cout << "hello from test dll 0" << std::endl;
return 0;
}

// testdll1.dll
#include "../include/TestHeader.h"
#include <iostream>

// This is an example of an exported function.
int TestFunction()
{
std::cout << "hello from test dll 1" << std::endl;
return 0;
}

Соберем testapp.exe с testdll0.dll, и копируем их на виртуалку (а именно там мы планируем запускать), а также подготовим testdll1.dll. Задачей нашего драйвера будет заменить testdll0 на testdll1. Мы поймем что у нас все получилось, если в консоли мы увидим сообщение “hello from test dll 1”, вместо “hello from test dll 0”. Запустим без драйвера, чтобы убедиться, что наше тестовое приложение работает корректно:



А теперь установим и запустим драйвер:



Теперь, запуская тоже самое приложение, мы получим совершенно другой вывод в консоль, так как загрузилась другая библиотека



Плюс в приложении, написанном для драйвера, мы видим логи, которые говорят, что мы действительно поймали наш запрос на открытие и заменили один файл на другой. Тест удался, пришло время проверить решение на самой игре. Наслаждаемся :)



Надеюсь, было полезно и интересно. Пишите ваши вопросы в комментариях, постараюсь ответить.