Windows Native Applications та сервіс Acronis Active Restore
Сьогодні продовжуємо розповідь про те, як ми разом із хлопцями з Університету Іннополіс розробляємо технологію 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);
Все, що нам потрібно – це використовувати 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"DeviceKeyboardClass0");
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
джерела:
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 послідовність запуску програм визначається значенням ключа реєстру:
Менеджер сесії почергово виконує програми із цього списку. Самі ж виконувані файли менеджер сесії шукає директорії system32. Формат значення ключа реєстру наступний:
autocheck autochk *MyNative
Значення має бути у шістнадцятковому форматі, а не у звичному ASCII, отже ключ, представлений вище, матиме формат:
Після встановлення та перезавантаження ще до появи екрану вибору користувачів ми отримаємо наступну картину:
Підсумок
На прикладі такого маленького програми ми переконалися, що запустити програму на рівні Windows Native цілком можливо. Далі ми з хлопцями з Університету Іннополіс продовжимо будувати сервіс, який ініціюватиме процес взаємодії з драйвером набагато раніше, ніж у попередній версії проекту. А з появою оболонки win32 логічно передатиме управління повноцінному сервісу, який вже був розроблений (про це докладніше тут).
У черговій статті ми торкнемося ще одного компонента сервісу Active Restore, а саме драйвера UEFI. Підписуйтесь на наш блог, щоб не пропустити наступну посаду.