25 августа 2011 г.

Шелл для синего экрана: Изучаем программирование на Native API на примере шелла...



Если программе проверки диска требуется исправить ошибки системного раздела, который не может быть отключен во время работы Windows, после перезагрузки программа запускается до открытия окна логина, отображая белые буквы на синем экране. Это — особый режим работы Windows, в котором еще не работает подсистема Win32, зато есть полный доступ к файлам и реестру.

Загрузочный экран

В Windows XP такой режим работы выглядит как синий экран с логотипом в верхнем правом углу, в Windows 2003 цвет этого экрана серый, в Vista и Windows 7 — черный. Самое частое приложение, которое ты можешь наблюдать работающим в этом режиме, это программа проверки диска. Обычно диск проверяется консольной программой chkdsk.exe. Но в загрузочном экране стартует вовсе не оно, как можно было бы подумать, а autochk.exe — приложение, написанное на чистом Native API. Только такие программы способны запуститься до загрузки подсистемы Win32.
Неплохо было бы написать шелл, командную строку для экспериментов, чтобы побродить по системе еще до ее полной загрузки, с возможностью редактировать файлы и реестр. Я загорелся этой идеей и занялся разработкой такого шелла. Первая версия программы была написана с использованием библиотеки ZenWinX, позже я изучил исходный код шелла NCLI из проекта TinyKRNL и решил строить свой собственный шелл на его основе. От использования ZenWinX я отказался, но часть исходного кода оттуда, связанная с обработкой клавиатурных комбинаций, перекочевала в новую версию, чтобы правильнее, чем в NCLI, реализовать работу с клавиатурой. В частности, NCLI даже не поддерживал переключение регистра символов по клавише Shift. К набору команд, доступных в NCLI, добавились команды, написанные мной. Так получилась программа, которую я назвал Native Shell.


Программирование

Программы, которые могут запускаться из «синего экрана», — это native-приложения, то есть такие приложения, которым доступны только функции NT Native API, экспортируемые библиотекой ntdll.dll.
Это набор функций, выполняющихся в режиме ядра, которые можно вызывать из пользовательского режима. Библиотека экспортирует два набора функций — с одинаковыми названиями, но разными префиксами: «Zw» и «Nt». Функции с префиксом «Zw» предназначены для прямого вызова из режима ядра (например, из драйверов), а функции с префиксом Nt — из пользовательского режима (из приложений). При вызове функции с префиксом «Nt» в итоге выполняется тот же код, что и при использовании префикса Zw, но при этом перед исполнением кода параметры функции проходят дополнительную проверку, так как с точки зрения системы пользовательский режим — это ненадежный источник входных данных.
Вообще, при программировании Native-приложений следует иметь в виду, что почти никаких готовых или заданных по умолчанию возможностей в этом режиме нет, разве что вывод букв на экран реализуется сравнительно просто. Все остальные операции, будь то ввод с клавиатуры или установка текущего рабочего каталога, не говоря уж об остальном, реализуется непосредственно самой программой.
Native API непривычен и не слишком удобен для использования, но другие API недоступны. Если в программировании с использованием WinAPI для какого-то действия требуется одна-две функции, то в Native API, возможно, потребуется пять-шесть. Это и не удивительно, ведь каждый вызов WinAPI внутри реализуется как раз вызовом нескольких функций из ntdll.dll.

Настройка проекта

Для написания собственного native-приложения понадобится документация, среда разработки и заголовочные файлы Native API. Функции NT Native API частично документированы в MSDN. Однако там описано далеко не все, что имеется в ntdll, так что информацию следует по возможности искать в других источниках.
В качестве среды разработки может выступить любой редактор кода, а компиляция native-приложения будет производиться утилитой build из Windows Driver Kit, так что его придется установить себе на машину. WDK используется для разработки драйверов, но способен также компилировать native-приложения.
Проект приложения в WDK выглядит как каталог, где лежат файлы исходного кода, рядом с которыми расположен файл конфигурации проекта под именем SOURCES. Так может выглядеть простой nativeпроект:
TARGETNAME=native
TARGETTYPE=PROGRAM
UMTYPE=nt
INCLUDES=$(DDK_INC_PATH)
SOURCES=native.c
PRECOMPILED_INCLUDE=precomp.h
Он отредактирован так, чтобы в результате компиляции собирался не драйвер, а native-приложение. Для этого параметру TARGETTYPE задано значение «PROGRAM», чтобы собиралась программа, а параметру UMTYPE задано значение «nt» , для того, чтобы тип приложения был native.
Сборка программы осуществляется командой build, набранной в командной строке Build Environment WDK. Перед сборкой следует скачать заголовочные файлы NDK (Native NT Toolkit), так как стандартных хидеров из WDK недостаточно, чтобы использовать все существующие функции Native API. Каталог ndk следует распаковать в папку, содержащую заголовочные файлы WDK, и прописать к ней путь в файле bin/setenv.bat (в строке «include=»).
Результатом сборки проекта будет .exe-файл приложения. Но это не обычный экзешник, так просто запустить его не получится. В PE-заголовке exe-файла есть специальное поле, означающее подсистему, в которой выполняется приложение. У native-приложений в это поле установлено значение 0x01, означающее, что .exe не требует подсистемы. У обычных приложений там содержится значение, соответствующее подсистемам «Windows GUI» (0x02) или «Windows console» (0x03). Из-за отличающегося значения этого поля Native-приложения не запускаются в обычном режиме работы Windows. При попытке запустить программу Windows выдает сообщение «Приложение нельзя запустить в режиме Win32». Запуск скомпилированного приложения в системе следует производить через прописывание его в ключ реестра BootExecute. Отлаживать приложение лучше на виртуальной машине, во-первых, из соображений удобства, а во-вторых, из соображений безопасности. Поскольку прописанное в BootExecute приложение запускается даже в «безопасном режиме» работы системы, ошибка в приложении может привести к невозможности нормальной загрузки Windows. В «безопасном режиме» будет показываться черный экран, но приложение будет работать, не имея при этом возможности вывести текст на дисплей.

Обработка команд

Шелл загрузочного экрана должен вести себя точно так же, как и шелл для любой другой среды, то есть воспринимать команды, набранные с клавиатуры, и выводить на экран результаты исполнения команд в текстовом виде. Чтобы шелл мог воспринимать ввод, он должен самостоятельно получить скан-коды с клавиатуры и преобразовать их в коды символов.
Для чтения с клавиатуры необходимо с помощью функции NtCreateFile открыть устройство клавиатуры как файл. Имя файла при этом будет выглядеть как «\Device\KeyboardClass0».
HANDLE hDriver;
UNICODE_STRING Driver;
OBJECT_ATTRIBUTES ObjectAttributes;
IO_STATUS_BLOCK Iosb;
RtlInitUnicodeString(&Driver, L"\\Device\\KeyboardClass0");
InitializeObjectAttributes(&ObjectAttributes, &Driver, OBJ_CASE_INSENSITIVE, NULL, NULL);
NtCreateFile(&hDriver, SYNCHRONIZE | GENERIC_READ | FILE_READ_ATTRIBUTES,&ObjectAttributes, &Iosb, NULL, FILE_ATTRIBUTE_NORMAL, 0, FILE_OPEN, FILE_DIRECTORY_FILE, NULL, 0);
Параллельно нужно создать событие (объект ядра типа Event), которое будет использоваться для ожидания ввода символов.
InitializeObjectAttributes(&ObjectAttributes, NULL, 0, NULL, NULL);
NtCreateEvent(&hEvent, EVENT_ALL_ACCESS,&ObjectAttributes, 1, 0);
Чтение с клавиатуры осуществляется функцией NtReadFile, которой в параметрах переданы хэндл клавиатуры и хэндл события.
IO_STATUS_BLOCK Iosb;
LARGE_INTEGER ByteOffset = 0;
NTSTATUS Status;
RtlZeroMemory(&Iosb, sizeof(Iosb));
Status = NtReadFile(hDriver, hEvent, NULL, NULL, &Iosb, Buffer, *BufferSize, &ByteOffset, NULL);
Следует проанализировать возвращаемое значение функции и при необходимости подождать наступления события с помощью
NtWaitForSingleObject.
if (Status == STATUS_PENDING)
{
    Status = NtWaitForSingleObject(hEvent, TRUE, NULL);
}
NtReadFile вернет данные в виде структуры KEYBOARD_INPUT_DATA.
Эта структура имеет следующий формат:
typedef struct _KEYBOARD_INPUT_DATA
{
    USHORT UnitId;
    USHORT MakeCode;
    USHORT Flags;
    USHORT Reserved;
ULONG ExtraInformation;
} KEYBOARD_INPUT_DATA, *PKEYBOARD_INPUT_DATA;
Поле MakeCode содержит сканкод нажатой клавиши, а поле Flags — необходимую дополнительную информацию о том, были ли нажаты одновременно Shift, Ctrl или что-то еще. Шелл должен содержать таблицу, из которой по сканкоду и флагам можно выбрать конкретный символ, соответствующий определенному сочетанию клавиш. Полученный символ можно возвратить из собственного аналога стандартной функции getch. Из символов можно складывать строки, а строки — обрабатывать как команды. Среди команд, к слову, следует обязательно предусмотреть команду завершения работы шелла, чтобы Windows могла загружаться в свое обычное состояние. Работа Native-приложения завершается вызовом функции NtTerminateProcess(NtCurrentProcess(), 0);
Вывод текста на экран чрезвычайно прост, он заключается в помещении в строку типа UNICODE_STRING какого-либо текста и последующем вызове функции NtDisplayString:
UNICODE_STRING unic;
RtlInitUnicodeString(&unic, L"Hello, world!\n");
NtDisplayString(&unic);
Функция поддерживает два управляющих символа — возврат каретки «\r» и перевод строки «\n».

Операции с файлами

Самые элементарные операции для командной строки — это отображение текущего каталога, перемещение между ними и операции над файлами. В native-режиме при всех операциях с файлами система ничего не знает о концепции «текущего каталога». Во всех файловых функциях требуется передавать полный путь, формат которого, к тому же, отличается от привычного. Существует функция, помогающая хранить значение текущего каталога, но сцеплять это значение с именем файла нужно самостоятельно.
Функции для установки и получения текущего каталога определены так:
NTSYSAPI ULONG NTAPI RtlGetCurrentDirectory_U(
ULONG MaximumLength,
PWSTR Buffer
);
NTSYSAPI NTSTATUS NTAPI RtlSetCurrentDirectory_U(
IN PUNICODE_STRING name
);
Для доступа к файлам и каталогам используется NT-формат пути. Это полный путь к файлу с буквой диска и префиксом \??\. Например, путь до файла C:\boot.ini будет выглядеть как \??\C:\boot.ini. Привычный формат пути без префикса называется в терминологии Native API «DOS-путь». Для конвертации пути из формата DOS в NT существует функция:
NTSYSAPI BOOLEAN NTAPI RtlDosPathNameToNtPathName_U(
IN PCWSTR DosPathName,
OUT PUNICODE_STRING NtPathName,
OUT PCWSTR *NtFileNamePart,
OUT CURDIR *DirectoryInfo
);
Целесообразно сохранять путь в DOS-формате. Его можно показывать пользователю без изменений, если он хочет увидеть текущий каталог. При каждой файловой операции придется формировать полный NTпуть к файлу вызовом соответствующей функции или прямой склейкой пути с префиксом.
Одна из необходимых возможностей шелла — это вывод листинга каталога. Чтобы его вывести, программа должна получить список файлов и каталогов текущей директории. Прежде всего, надо открыть каталог функцией NtCreateFile с опцией FILE_LIST_DIRECTORY и указанием флага FILE_DIRECTORY_FILE. Полученный хэндл скармливается функции NtQueryDirectoryFile, которой передается константа FileBothDirectoryInformation и указатель на буфер данных типа FILE_ BOTH_DIR_INFORMATION. Структура этого типа позволяет узнать о файлах и каталогах все их важные параметры: имя, атрибуты, размер и время создания.
typedef struct _FILE_BOTH_DIR_INFORMATION
{
    ULONG NextEntryOffset;
    ULONG FileIndex;
    LARGE_INTEGER CreationTime;
    LARGE_INTEGER LastAccessTime;
    LARGE_INTEGER LastWriteTime;
    LARGE_INTEGER ChangeTime;
    LARGE_INTEGER EndOfFile;
    LARGE_INTEGER AllocationSize;
    ULONG FileAttributes;
    ULONG FileNameLength;
    ULONG EaSize;
    CCHAR ShortNameLength;
    WCHAR ShortName[12];
    WCHAR FileName[1];
} FILE_BOTH_DIR_INFORMATION, *PFILE_BOTH_DIR_INFORMATION;
При вызове функции NtQueryDirectoryFile можно использовать параметр ReturnSingleEntry = TRUE, тогда за один вызов функции в буфер будет помещена только одна структура FILE_BOTH_DIR_ INFORMATION, а вызвать функцию в цикле придется столько раз, сколько файлов в каталоге. При установке того же параметра в FALSE функция будет вызвана всего один раз, а в буфере окажется массив структур. Перемещаться по нему можно, сдвигая указатель на структуру по смещению, указанному в поле NextEntryOffset. У последнего элемента массива значение этого поля будет NULL.
Функции для стандартных файловых операций, таких как чтение из файла, запись в файл и удаление файла, документированы в MSDN. Их названия NtReadFile, NtWriteFile, NtDeleteFile соответственно, а использование мало чем отличается от привычных функций WinAPI.
Чтобы скопировать файл, нужно просто прочитать его из одного места и записать копию в другом. А вот переименование файла — более комплексная операция, поэтому стоит рассмотреть ее подробнее.

Переименование файла

Существует функция NtSetInformationFile, которая может производить множество различных операций над файлом. Нас интересует операция переименования. Прототип функции выглядит так:
NTSYSCALLAPI NTSTATUS NTAPI NtSetInformationFile(
IN HANDLE FileHandle,
IN PIO_STATUS_BLOCK IoStatusBlock,
IN PVOID FileInformation,
IN ULONG Length,
IN FILE_INFORMATION_CLASS FileInformationClass
);
В параметре FileInformationClass передается константа
FileRenameInformation, означающая операцию переименования. В сочетании с этой константой функция получает в параметре FileInformation указатель на структуру FILE_RENAME_INFORMATION.
typedef struct _FILE_RENAME_INFORMATION
{
BOOLEAN ReplaceIfExists;
HANDLE RootDirectory;
ULONG FileNameLength;
WCHAR FileName[1];
} FILE_RENAME_INFORMATION, *PFILE_RENAME_INFORMATION;
Структура FILE_RENAME_INFORMATION имеет переменную длину, зависящую от длины нового имени файла. Нужно выделить для структуры достаточное количество памяти. Предположим, у тебя есть буфер NewFileName с новым именем файла и его размер в переменной FileNameSize.
PFILE_RENAME_INFORMATION FileRenameInfo;
FileRenameInfo = RtlAllocateHeap(RtlGetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(FILE_RENAME_INFORMATION) + FileNameSize);
После выделения памяти следует скопировать буфер NewFileName в поле структуры FileName и проинициализировать другие ее поля. Поле ReplaceIfExists определяет, заменять ли существующий файл, если его имя совпадает с новым именем файла при переименовании. В параметре RootDirectory может содержаться хэндл другой директории, в которой должен оказаться файл после перемещения.
Проще оставить это поле равным NULL, ведь для перемещения файла в другой каталог достаточно указать в поле FileName полный путь к новому расположению файла в NT-формате. Если осуществляется переименование файла, а не перемещение, в FileName должно быть только имя файла. После инициализации структуры остается только вызвать функцию для осуществления операции:
Status = NtSetInformationFile(
FileHandle,
&IoStatusBlock,
FileRenameInfo,
sizeof(FILE_RENAME_INFORMATION)+ FileNameSize,
FileRenameInformation
);
Размер буфера FileRenameInfo нельзя считать равным sizeof(FILE_ RENAME_INFORMATION), ведь в определении структуры не учтена изменчивая длина поля FileName. Поэтому в четвертом параметре Length следует передать длину структуры, к которой прибавлен размер строки FileName.

Реестр

Для операций с реестром используются документированные в MSDN функции, названия которых оканчиваются на «-Key», например, для чтения из реестра используется NtQueryValueKey.
На уровне Native API реестр выглядит немного не так, как в Win32. Вместо нескольких корневых псевдоключей HKEY_XXX используется единственный ключ «\REGISTRY» с двумя подключами «\USER» и «\ MACHINE». Эти два ключа соответствуют «HKEY_USERS» и «HKEY_ LOCAL_MACHINE». Эквивалента ключу «HKEY_CURRENT_USER» нет, ветки разных пользователей следует искать в «\USER». Ключу «HKEY_ CLASSES_ROOT» соответствуют разные ветви реестра, располагающиеся как в ветке «\USER», так и в «\MACHINE». Еще одно отличие от WinAPI в том, что работая с реестром, мы оперируем обычным типом HANDLE, а не специальным типом HKEY.
Дэниэл Мэдден еще в 2006 году написал программу с открытым исходным кодом под названием NtRegEdit — аналог стандартного редактора реестра (regedit.exe). NtRegEdit использует для доступа к реестру только функции Native API, поэтому код из программы можно перенести в свое собственное native-приложение.
В библиотеке ZenWinX также присутствует код, использующий функции реестра. Например, функция winx_register_boot_exec_command умеет, как видно из названия, прописывать команду, выполняющуюся при запуске, то есть выполнять запись в ключ реестра BootExecute.
Библиотека ntreg корейского программиста rodream содержит набор функций для работы с реестром — достаточно просто подключить к своему проекту файлы ntreg.c и ntreg.h, и программа может в него читать и писать. В этой библиотеке отсутствует функция вывода списка ключей и значений из заданной ветки реестра, но, к счастью, ее несложно написать самостоятельно.
Чтобы узнать, какие подключи есть у какого-либо ключа, используется функция NtEnumerateKey.
NTSYSCALLAPI NTSTATUS NTAPI NtEnumerateKey(
IN HANDLE KeyHandle,
IN ULONG Index,
IN KEY_INFORMATION_CLASS KeyInformationClass,
OUT PVOID KeyInformation,
IN ULONG Length,
OUT PULONG ResultLength
);
Имена подключей будут браться из указателя на структуру KEY_ NODE_INFORMATION. В параметр KeyInformationClass записываем константу KeyNodeInformation, в параметр KeyInformation помещаем указатель на структуру. Код для получения всех подключей в итоге будет выглядеть следующим образом:
ULONG ResultLength, i = 0;
char buf[BUFFER_SIZE];
PKEY_NODE_INFORMATION pki = (PKEY_NODE_INFORMATION)buf;
while (STATUS_SUCCESS == NtEnumerateKey(hKey, i++, KeyNodeInformation, pki, BUFFER_SIZE, &ResultLength))
{
    ;
}
Внутри этого цикла очередное имя подключа доступно как строка WCHAR pki->Name, ее можно выводить на экран или сохранять в какой-нибудь внутренний список. Похожим образом можно получить список всех значений, содержащихся в ключе реестра, только используется другая функция NtEnumerateValueKey с константой KeyValueBasicInformation, а результат оказывается в структуре KEY_ VALUE_BASIC_INFORMATION.
pbi = (PKEY_VALUE_BASIC_INFORMATION)buf;
while (STATUS_SUCCESS == NtEnumerateValueKey(hKey, i++, KeyValueBasicInformation, pbi, BUFFER_SIZE, &ResultLength))
{
    ;
}
Имя находится в строке pbi->Name, а тип значения (REG_SZ, REG_DWORD или другой) определяется в pbi->Type.

Запуск процессов

Неплохо иметь в шелле возможность запускать другие процессы. Это сразу расширяет применяемость программы, ведь если программа может запускать процессы, ее функциональность уже не ограничена операциями, зашитыми в ее код. Дальнейшее расширение доступных действий в native-режиме можно осуществлять разработкой новых программ. Да и запускать native-приложения, поставляемые с операционной системой, тоже можно. В загрузочном режиме невозможен запуск Win32-приложений, так как процессы подсистемы Win32 при создании требуют уведомления CSRSS о новом процессе (а он еще неактивен). Поэтому подавляющее большинство утилит Windows запуститься не смогут, за исключением лишь немногих программ, таких как autochk.exe, autofmt.exe (аналоги Win32-утилит chkdsk.exe и format.exe для проверки и форматирования диска), srdelayed.exe (программа отложенных операций с файлами).
Чтобы запустить из native-программы другую такую же программу, используется функция RtlCreateUserProcess. Ей передаются параметры запускаемого процесса в виде структуры типа RTL_USER_PROCESS_ PARAMETERS, которая инициализируется специальной функцией RtlCreateProcessParameters. Именно в эту структуру помещают полный путь к исполняемому файлу в NT-формате, название для отображения в списке процессов и командную строку приложения. После запуска функция помещает параметры процесса в заранее приготовленный буфер RTL_USER_PROCESS_INFORMATION. Оттуда берется тред потока и передается в функцию NtResumeThread, чтобы поток начал выполняться. С этого момента новый процесс запущен.
Перед запуском процесса неплохо бы отключаться от обработки клавиатуры, то есть закрыть ее хэндл, а также хэндл обработки ее событий. Это позволит вновь запущенному приложению обрабатывать клавиатуру самостоятельно, без дублирования обработки в шелле. Восстанавливать контроль над клавиатурой можно после завершения запущенного процесса. Чтобы дождаться завершения процесса, нужно всего лишь извлечь его хэндл из поля ProcessHandle структуры RTL_USER_PROCESS_INFORMATION и передать его в функцию NtWaitForSingleObject, которая приостановит выполнение текущего процесса до завершения запущенного.
Для проверки возможности запуска процессов можно запустить autochk.exe так, чтобы запустилась проверка системного диска. Для этого следует в RtlCreateUserProcess передать следующие строки:
  • имя для отображения в списке процессов: autochk.exe
  • командная строка: autochk.exe /p \??\C:
  • полный путь: \??\C:\windows\system32\autochk.exe

Итог

Native-приложения — это самый низкий уровень взаимодействия приложения с системой в пользовательском режиме. Режим native-загрузки сочетает в себе почти неограниченный доступ к потрохам системы с возможностью выполнять различные действия в интерактивном режиме. Я уверен, что освоив написание программ на чистом Native API, ты найдешь для них множество интересных применений.

Ключи запуска

Автозапуск native-приложений задается в ветке реестра HKEY_LOCAL_ MACHINE\CurrentControlSet\Control\Session Manager. Здесь есть два ключа, позволяющих запустить приложение на этапе загрузки системы. Обычно присутствует только один из них, BootExecute. Это мультистроковый параметр, содержащий строку «autocheck autochk *». После нее можно добавить свою команду запуска. Например, можно поместить native.exe в папку %systemroot%\ system32, а в BootExecute прописать строку «native». В результате native.exe запустится сразу после autochk.exe при запуске системы. Здесь же можно указать командную строку процесса, например «native some-command».
Чтобы запустить программу из любого каталога системы, нужно указать полный путь к исполняемому файлу без NT-префикса, то есть в обычном формате (например, C:\tmp\native.exe). При указании имени native-приложения кроме идентификатора autocheck (используется при указании autochk в этом списке) возможны идентификаторы async и debug. Идентификатор debug приводит к установке ProcessParameters -> DebugFlags = TRUE. Идентификатор async приводит к тому, что система не ожидает завершения запускаемого процесса, и оно продолжает работать, а система в это время продолжает загрузку. В результате получается приложение, работающее в живой системе, отображающееся в диспетчере задач как запущенное от имени пользователя SYSTEM.
Второй ключ реестра, через который возможен запуск, носит название SetupExecute и полностью аналогичен BootExecute. Разница между ними в том, что запуск из этих ключей происходит на разных этапах инициализации системы. На этапе запуска из SetupExecute в системе уже создан файл подкачки и инициализированы переменные среды, а на этапе BootExecute еще нет.

WWW

  • Сайт Томаша Новака. Удобный справочник, содержащий описания функций и типов данных, разбитый на категории: undocumented.ntinternals.net.
  • MSDN. Неполная документация по функциям ntdll.dll доступна в Microsoft Developers Network в разделе о Windows Driver Kit: msdn.microsoft.com.
  • Native Shell. Командная строка для native-режима от автора статьи: hex.pp.ua/nt-native-applications-shell.php.
  • Исходные коды ReactOS — свободной альтернативы Windows. Редкая возможность не только увидеть множество примеров использования Native API функций, но и заглянуть немного дальше — в их внутреннее устройство: svn.reactos.org/svn/reactos.
  • Библиотека ZenWinX, упрощающая программирование в nativeрежиме: zenwinx.sourceforge.net.
  • Native Development Kit (NDK) от Алекса Ионеску. Набор заголовочных файлов, содержащих все типы и функции Native API: code.google.com/p/native-nt-toolkit.

Комментариев нет:

Отправить комментарий