Сегодня продолжаем рассказ о том, как мы вместе с ребятами из Университета Иннополис разрабатываем технологию Active Restore, чтобы позволить пользователю как можно раньше начать работу на своей машине после сбоя. Речь пойдет о нативных приложениях Windows, включая особенности их создания и запуска. Под катом – немного о нашем проекте, а также практическое руководство как писать нативные приложения.
В прошлых постах мы уже рассказывали о том, что такое Active Restore, и как студенты из Иннополиса разрабатывают сервис. Сегодня я хочу остановиться на нативных приложениях, до уровня которых мы хотим “закопать” наш сервис активного восстановления. Если все получится, то мы сможем:
- Намного раньше запустить сам сервис
- Намного раньше связаться с облаком, в котором лежит бэкап
- Намного раньше понять, в каком режиме находится система – нормальной загрузки или восстановления
- Намного меньше файлов восстанавливать заранее
- Позволить пользователю приступить к работе еще быстрее.
Что вообще такое нативное приложение?
Чтобы ответить на этот вопрос, давайте взглянем на последовательность вызовов, которые совершает система, например, если программист в своем приложении пытается создать файл.
Pavel Yosifovich — Windows Kernel Programming (2019)
Программист использует функцию CreateFile, которая объявлена в заголовочном файле fileapi.h и реализована в Kernel32.dll. Однако сама эта функция не занимается созданием файла, она лишь проверяет аргументы на входе и вызывает функцию NtCreateFile (приставка Nt как раз свидетельствует о том, что функция нативная). Данная функция объявлена в заголовочном файле winternl.h и реализована в ntdll.dll. Она производит подготовку к прыжку в ядерное пространство, после чего совершает системный вызов для создания файла. В данном случае получается, что Kernel32 – всего лишь обертка для Ntdll. Одна из причин для чего это сделано, Microsoft таким образом имеет возможность изменять функции нативного мира, но при этом не трогать стандартные интерфейсы. Microsoft не рекомендует напрямую вызывать нативные функции и не документирует большую часть из них. Кстати, недокументированные функции можно найти тут.
Основное преимущество нативных приложений заключается в том, что ntdll загружается в систему значительно раньше kernel32. Это логично, ведь kernel32 требует наличия ntdll для работы. Как следствие, приложения, использующие нативные функции, могут начать работу значительно раньше.
Таким образом, Windows Native Applications – это программы, способные запускаться на раннем этапе загрузки Windows. Они используют ТОЛЬКО функции из ntdll. Пример такого приложения: autochk который исполняет chkdisk utility для проверки диска на ошибки еще до запуска основных сервисов. Именно на таком уровне мы и хотим видеть наш Active Restore.
Что нам понадобится?
- DDK (Driver Development Kit), ныне также известный под названием WDK 7 (Windows Driver Kit).
- Виртуальная машина (например, Windows 7 x64)
- Не обязательно, но могут помочь заголовочные файлы которые можно скачать тут
Что же в коде?
Давайте немного потренируемся и для примера напишем небольшое приложение которое:
- Выводит сообщение на экран
- Аллоцирует немного памяти
- Ждет ввода с клавиатуры
- Освобождает занятую память
В нативных приложениях точкой входа является не main или winmain, а функция NtProcessStartup, так как мы фактически напрямую запускаем новые процесс в системе.
Начнем с вывода сообщения на экран. Для этого у нас есть нативная функция NtDisplayString, которая в качестве аргумента принимает указатель на объект структуры UNICODE_STRING. Инициализировать его нам поможет RtlInitUnicodeString. В результате, для вывода текста на экран мы можем написать вот такую небольшую функцию:
//usage: WriteLn(L"Here is my textn");
void WriteLn(LPWSTR Message)
{
UNICODE_STRING string;
RtlInitUnicodeString(&string, Message);
NtDisplayString(&string);
}
Так как нам доступны только функции из ntdll, и других библиотек в памяти просто еще нет, у нас обязательно возникнут проблемы с тем как аллоцировать память. Оператора new ещё не существует (потому что он родом из слишком высокоуровнего мира C++), также нет функции malloc (для нее нужны библиотеки runtime C). Можно конечно пользоваться лишь стэком. Но если нам нужно динамически аллоцировать память, делать это придется в куче (т.е. heap). Поэтому давайте создадим для себя кучу и будем брать из нее память когда нам потребуется.
Для этой задачи подойдет функция RtlCreateHeap. Далее, используя RtlAllocateHeap и RtlFreeHeap, мы будем занимать и освобождать память когда нам это будет нужно.
PVOID memory = NULL;
PVOID buffer = NULL;
ULONG bufferSize = 42;
// create heap in order to allocate memory later
memory = RtlCreateHeap(
HEAP_GROWABLE,
NULL,
1000,
0, NULL, NULL
);
// allocate buffer of size bufferSize
buffer = RtlAllocateHeap(
memory,
HEAP_ZERO_MEMORY,
bufferSize
);
// free buffer (actually not needed because we destroy heap in next step)
RtlFreeHeap(memory, 0, buffer);
RtlDestroyHeap(memory);
Перейдем к ожиданию ввода с клавиатуры.
// https://docs.microsoft.com/en-us/windows/win32/api/ntddkbd/ns-ntddkbd-keyboard_input_data
typedef struct _KEYBOARD_INPUT_DATA {
USHORT UnitId;
USHORT MakeCode;
USHORT Flags;
USHORT Reserved;
ULONG ExtraInformation;
} KEYBOARD_INPUT_DATA, *PKEYBOARD_INPUT_DATA;
//...
HANDLE hKeyBoard, hEvent;
UNICODE_STRING skull, keyboard;
OBJECT_ATTRIBUTES ObjectAttributes;
IO_STATUS_BLOCK Iosb;
LARGE_INTEGER ByteOffset;
KEYBOARD_INPUT_DATA kbData;
// inialize variables
RtlInitUnicodeString(&keyboard, L"\Device\KeyboardClass0");
InitializeObjectAttributes(&ObjectAttributes, &keyboard, OBJ_CASE_INSENSITIVE, NULL, NULL);
// open keyboard device
NtCreateFile(&hKeyBoard,
SYNCHRONIZE | GENERIC_READ | FILE_READ_ATTRIBUTES,
&ObjectAttributes,
&Iosb,
NULL,
FILE_ATTRIBUTE_NORMAL,
0,
FILE_OPEN,FILE_DIRECTORY_FILE,
NULL, 0);
// create event to wait on
InitializeObjectAttributes(&ObjectAttributes, NULL, 0, NULL, NULL);
NtCreateEvent(&hEvent, EVENT_ALL_ACCESS, &ObjectAttributes, 1, 0);
while (TRUE)
{
NtReadFile(hKeyBoard, hEvent, NULL, NULL, &Iosb, &kbData, sizeof(KEYBOARD_INPUT_DATA), &ByteOffset, NULL);
NtWaitForSingleObject(hEvent, TRUE, NULL);
if (kbData.MakeCode == 0x01) // if ESC pressed
{
break;
}
}
Все что нам нужно – это использовать NtReadFile на открытом устройстве, и ждать, пока клавиатура не вернет нам какое либо нажатие. В случае, если нажата клавиша ESC, мы продолжим работу. Чтобы открыть устройство, нам потребуется вызвать функцию NtCreateFile (открыть нужно будет DeviceKeyboardClass0). Также мы вызовем NtCreateEvent, чтобы инициализировать объект для ожидания. Мы самостоятельно объявим структуру KEYBOARD_INPUT_DATA, которая представляет данные клавиатуры. Это облегчит нам работу.
Работа нативного приложения завершается вызовом функции NtTerminateProcess, потому что мы просто убиваем свой собственный процесс.
Весь код нашего небольшого приложения:
#include "ntifs.h" // WinDDK7600.16385.1incddk
#include "ntdef.h"
//------------------------------------
// Following function definitions can be found in native development kit
// but I am too lazy to include `em so I declare it here
//------------------------------------
NTSYSAPI
NTSTATUS
NTAPI
NtTerminateProcess(
IN HANDLE ProcessHandle OPTIONAL,
IN NTSTATUS ExitStatus
);
NTSYSAPI
NTSTATUS
NTAPI
NtDisplayString(
IN PUNICODE_STRING String
);
NTSTATUS
NtWaitForSingleObject(
IN HANDLE Handle,
IN BOOLEAN Alertable,
IN PLARGE_INTEGER Timeout
);
NTSYSAPI
NTSTATUS
NTAPI
NtCreateEvent(
OUT PHANDLE EventHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL,
IN EVENT_TYPE EventType,
IN BOOLEAN InitialState
);
// https://docs.microsoft.com/en-us/windows/win32/api/ntddkbd/ns-ntddkbd-keyboard_input_data
typedef struct _KEYBOARD_INPUT_DATA {
USHORT UnitId;
USHORT MakeCode;
USHORT Flags;
USHORT Reserved;
ULONG ExtraInformation;
} KEYBOARD_INPUT_DATA, *PKEYBOARD_INPUT_DATA;
//----------------------------------------------------------
// Our code goes here
//----------------------------------------------------------
// usage: WriteLn(L"Hello Native World!n");
void WriteLn(LPWSTR Message)
{
UNICODE_STRING string;
RtlInitUnicodeString(&string, Message);
NtDisplayString(&string);
}
void NtProcessStartup(void* StartupArgument)
{
// it is important to declare all variables at the beginning
HANDLE hKeyBoard, hEvent;
UNICODE_STRING skull, keyboard;
OBJECT_ATTRIBUTES ObjectAttributes;
IO_STATUS_BLOCK Iosb;
LARGE_INTEGER ByteOffset;
KEYBOARD_INPUT_DATA kbData;
PVOID memory = NULL;
PVOID buffer = NULL;
ULONG bufferSize = 42;
//use it if debugger connected to break
//DbgBreakPoint();
WriteLn(L"Hello Native World!n");
// inialize variables
RtlInitUnicodeString(&keyboard, L"\Device\KeyboardClass0");
InitializeObjectAttributes(&ObjectAttributes, &keyboard, OBJ_CASE_INSENSITIVE, NULL, NULL);
// open keyboard device
NtCreateFile(&hKeyBoard,
SYNCHRONIZE | GENERIC_READ | FILE_READ_ATTRIBUTES,
&ObjectAttributes,
&Iosb,
NULL,
FILE_ATTRIBUTE_NORMAL,
0,
FILE_OPEN,FILE_DIRECTORY_FILE,
NULL, 0);
// create event to wait on
InitializeObjectAttributes(&ObjectAttributes, NULL, 0, NULL, NULL);
NtCreateEvent(&hEvent, EVENT_ALL_ACCESS, &ObjectAttributes, 1, 0);
WriteLn(L"Keyboard readyn");
// create heap in order to allocate memory later
memory = RtlCreateHeap(
HEAP_GROWABLE,
NULL,
1000,
0, NULL, NULL
);
WriteLn(L"Heap readyn");
// allocate buffer of size bufferSize
buffer = RtlAllocateHeap(
memory,
HEAP_ZERO_MEMORY,
bufferSize
);
WriteLn(L"Buffer allocatedn");
// free buffer (actually not needed because we destroy heap in next step)
RtlFreeHeap(memory, 0, buffer);
RtlDestroyHeap(memory);
WriteLn(L"Heap destroyedn");
WriteLn(L"Press ESC to continue...n");
while (TRUE)
{
NtReadFile(hKeyBoard, hEvent, NULL, NULL, &Iosb, &kbData, sizeof(KEYBOARD_INPUT_DATA), &ByteOffset, NULL);
NtWaitForSingleObject(hEvent, TRUE, NULL);
if (kbData.MakeCode == 0x01) // if ESC pressed
{
break;
}
}
NtTerminateProcess(NtCurrentProcess(), 0);
}
PS: Мы можем запросто использовать в коде функцию DbgBreakPoint() для остановки в дебаггере. Правда нужно будет подключить WinDbg к виртуальной машине для кернельной отладки. Инструкцию как это сделать можно найти тут или просто использовать VirtualKD.
Компиляция и сборка
Самый простой способ собрать нативное приложение – это использовать DDK (Driver Development Kit). Нам нужна именно древняя седьмая версия, так как более поздние версии имеют несколько иной подход и тесно работают с Visual Studio. Если же использовать DDK, то нашему проекту нужны всего лишь Makefile и sources.
Makefile
!INCLUDE $(NTMAKEENV)makefile.def
sources:
TARGETNAME = MyNative
TARGETTYPE = PROGRAM
UMTYPE = nt
BUFFER_OVERFLOW_CHECKS = 0
MINWIN_SDK_LIB_PATH = $(SDK_LIB_PATH)
SOURCES = source.c
INCLUDES = $(DDK_INC_PATH);
C:WinDDK7600.16385.1ndk;
TARGETLIBS = $(DDK_LIB_PATH)ntdll.lib
$(DDK_LIB_PATH)nt.lib
USE_NTDLL = 1
Ваш Makefile будет точно таким же, на sources же давайте остановимся чуть подробнее. В данном файле указываются исходники вашей программы (файлы .c), опции сборки и другие параметры.
- TARGETNAME – имя исполняемого файла, который должен получиться в итоге.
- TARGETTYPE – тип исполняемого файла, это может быть драйвер (.sys), тогда значение поля должно быть DRIVER, если библиотека (.lib), то значение LIBRARY. В нашем случае нужен исполняемый файл (.exe), поэтому мы устанавливаем значение PROGRAM.
- UMTYPE – возможные значения этого поля: console для консольного приложения, windows для работы в оконном режиме. Но нам необходимо указать nt, чтобы получить нативное приложение.
- BUFFER_OVERFLOW_CHECKS – проверка стэка на переполнение буфера, к сожалению не наш случай, выключаем.
- MINWIN_SDK_LIB_PATH – данное значение ссылается на переменную SDK_LIB_PATH, не стоит переживать что у вас не объявлена подобная системная переменная, в момент когда мы запустим checked build из DDK, данная переменная будет объявлена и будет указывать на необходимые библиотеки.
- SOURCES – список исходников вашей программы.
- INCLUDES – заголовочные файлы, которые необходимы для сборки. Тут обычно указывают путь к файлам, которые идут в комплекте с DDK, но вы можете указать дополнительно любые другие.
- TARGETLIBS – список библиотек, которые необходимо линковать.
- USE_NTDLL – обязательное поле, которое необходимо установить в положение 1. По вполне очевидным причинам.
- USER_C_FLAGS – любые флаги, которые вы сможете использовать в препроцессорных директивах при подготовке кода приложения.
Итак для сборки нам необходимо запустить x86 (или x64) Checked Build, сменить рабочий каталог на папку с проектом и выполнить команду Build. Результат на скриншоте показывает что у нас собрался один исполняемый файл.
Данный файл не получится так просто запустить, система ругается и отправляет нас думать о своем поведении со следующей ошибкой:
Как запустить нативное приложение?
В момент старта autochk последовательность запуска программ определяется значением ключа реестра:
HKLMSystemCurrentControlSetControlSession ManagerBootExecute
Менеджер сессии поочередно исполняет программы из этого списка. Сами же исполняемые файлы менеджер сессии ищет в директории system32. Формат значения ключа реестра следующий:
autocheck autochk *MyNative
Значение должно быть в шестнадцатеричном формате, а не в привычном ASCII, следовательно ключ, представленный выше, будет иметь формат:
61,75,74,6f,63,68,65,63,6b,20,61,75,74,6f,63,68,6b,20,2a,00,4d,79,4e,61,74,69,76,65,00,00
Чтобы конвертировать название, можно использовать онлайн-сервис, например, этот.
Получается, чтобы запустить нативное приложение, нам необходимо:
- Скопировать исполняемый файл в папку system32
- Добавить в реестр ключ
- Перезагрузить машину
Для удобства вот вам готовый скрипт для установки нативного приложения:
install.bat
@echo off
copy MyNative.exe %systemroot%system32.
regedit /s add.reg
echo Native Example Installed
pause
add.reg
REGEDIT4
[HKEY_LOCAL_MACHINESYSTEMCurrentControlSetControlSession Manager]
"BootExecute"=hex(7):61,75,74,6f,63,68,65,63,6b,20,61,75,74,6f,63,68,6b,20,2a,00,4d,79,4e,61,74,69,76,65,00,00
После установки и перезагрузки еще до появления экрана выбора пользователей мы получим следующую картину:
Итог
На примере вот такого маленького приложения мы убедились, что запустить приложение на уровне Windows Native вполне возможно. Дальше мы с ребятами из Университета Иннополис продолжим строить сервис, который будет инициировать процесс взаимодействия с драйвером намного раньше, чем в предыдущей версии нашего проекта. А с появлением оболочки win32 логично будет передать управление полноценному сервису, который уже был разработан (об этом подробнее здесь).
В очередной статье мы коснемся еще одного компонента сервиса Active Restore, а именно UEFI драйвера. Подписывайтесь на наш блог, чтобы не пропустить следующий пост.
Автор: Dabudabot