Windows Native Applications та сервіс Acronis Active Restore

Сьогодні продовжуємо розповідь про те, як ми разом із хлопцями з Університету Іннополіс розробляємо технологію Active Restore, щоб дозволити користувачеві якомога раніше розпочати роботу на своїй машині після збою. Йтиметься про нативні програми Windows, включаючи особливості їх створення та запуску. Під катом – трохи про наш проект, а також практичне керівництво, як писати нативні програми.

Windows Native Applications та сервіс Acronis Active Restore

У минулих постах ми вже розповідали про те, що таке Active Restore, і як студенти з Іннополісу розробляють сервіс. Сьогодні я хочу зупинитися на нативних програмах, до рівня яких ми хочемо “закопати” наш сервіс активного відновлення. Якщо все вийде, то зможемо:

  • Набагато раніше запустити сам сервіс
  • Набагато раніше зв'язатися з хмарою, в якій лежить бекап
  • Набагато раніше зрозуміти, в якому режимі знаходиться система – нормального завантаження чи відновлення
  • Набагато менше файлів відновлювати заздалегідь
  • Дозволити користувачеві почати роботу ще швидше.

Що взагалі такий нативний додаток?

Щоб відповісти на це питання, погляньмо на послідовність викликів, які здійснює система, наприклад, якщо програміст у своїй програмі намагається створити файл.

Windows Native Applications та сервіс Acronis 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)
  • Не обов'язково, але можуть допомогти заголовні файли, які можна завантажити тут

Що ж у коді?

Давайте трохи потренуємося і для прикладу напишемо невелику програму яка:

  1. Виводить повідомлення на екран
  2. Алокує трохи пам'яті
  3. Чекає на введення з клавіатури
  4. Звільняє зайняту пам'ять

У нативних додатках точкою входу є не 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"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);

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"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. Результат на скріншоті показує що у нас зібрався один файл, що виконується.

Windows Native Applications та сервіс Acronis Active Restore

Даний файл не вдасться так просто запустити, система лається і відправляє нас думати про свою поведінку з наступною помилкою:

Windows Native Applications та сервіс Acronis Active Restore

Як запустити нативний додаток?

У момент старту 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

Щоб конвертувати назву, можна використовувати онлайн-сервіс, наприклад, цей.

Windows Native Applications та сервіс Acronis Active Restore
Виходить, щоб запустити нативний додаток, нам необхідно:

  1. Копіювати файл у папку system32
  2. Додати до реєстру ключ
  3. Перезавантажити машину

Для зручності ось вам готовий скрипт для встановлення нативної програми:

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 Applications та сервіс Acronis Active Restore

Підсумок

На прикладі такого маленького програми ми переконалися, що запустити програму на рівні Windows Native цілком можливо. Далі ми з хлопцями з Університету Іннополіс продовжимо будувати сервіс, який ініціюватиме процес взаємодії з драйвером набагато раніше, ніж у попередній версії проекту. А з появою оболонки win32 логічно передатиме управління повноцінному сервісу, який вже був розроблений (про це докладніше тут).

У черговій статті ми торкнемося ще одного компонента сервісу Active Restore, а саме драйвера UEFI. Підписуйтесь на наш блог, щоб не пропустити наступну посаду.

Джерело: habr.com

Додати коментар або відгук