Содержание статьи
У Стивена Кинга есть произведение «Потаенное окно, потаенный сад». Не могу сказать, что я люблю творчество этого писателя, но если ты не читал эту книгу, настоятельно советую найти и прочесть.
Очень занимательная и одновременно пугающая книга. В ней со всей присущей Стивену Кингу ужасающей красотой изложения рассказывается о том, какие тайны может хранить в себе сознание любого человека.
Вот и сегодня мы, наверное, не будем разговаривать на какую-то конкретную тему. Мы просто немного полазаем в потаенном саду Windows, забравшись туда через потаенное окно дебаггера :). Я попробую рассказать о скрытых местах, странностях и неизвестностях операционной системы Windows. Эти знания помогут тебе, как программисту, лучше знать, понимать и использовать эти самые потаенные места в своих грязных целях.
Введение
Даже по прошествии многих лет, потраченных на изучение внутренностей операционной системы и системного кодинга, понимаешь, что постичь все тонкости ОС вряд ли удастся. Я не имею в виду именно себя — такого мнения придерживаются многие программисты, с которыми я знаком.
При этом зачастую единственным инструментом, позволяющим выпытать те или иные секреты операционной системы, становится отладчик или дебаггер. Хотя не все любят возиться с отладчиком, положения дел это не меняет — если хочешь находить, простите за каламбур, потаенные окна в Windows — без него не обойтись. Итак, начнем.
Загадочный параметр LPRESERVED в DLLMAIN
Всем нам известна точка входа при старте библиотек — DllMain:
BOOL WINAPI DllMain(
__in HINSTANCE hinstDLL,
__in DWORD dwReason,
__in LPVOID lpReserved
);
Принимает она (точка входа) три параметра. С первыми двумя все понятно, но как быть с третьим? И действительно, зачем нужен этот параметр lpReserved, если нигде в коде при инициализации библиотеки он больше не используется? Оказывается не все так просто, как пытается это показать Microsoft.
MSDN утверждает, что этот параметр используется при загрузке/выгрузке библиотеки; в частности, при статических операциях библиотеки этот параметр содержит отличное от нуля значение. И, наоборот, при динамических операциях lpReserved будет равным нулю. Открою страшную тайну: lpReserved есть ничто иное, как указатель на контекст стартующего процесса, который грузит библиотеку! Подробности таковы: при старте нового потока ядро ставит его в очередь для исполнения в виде APC — AsyncProcedureCall, который передается в функцию LdrInitializeThunk, который вызывается Ntdll.dll.
Одним из параметров, который передается LdrInitializeThunk, является указатель на структуру CONTEXT, которая описывает начальное состояние потока — регистры, данные и т.п. После выполнения APC, контроль передается LdrInitializeThunk. Раз уж исполнение нового потока начинается с вызова ntdll!LdrInitializeThunk, то этой функции передается стартовый адрес, определенный функцией CreateThread. Таким образом становится понятно (а уж под отладчиком — тем более!), что CreateThread должен передать через APC в вызов LdrInitializeThunk параметры старта процесса.
Подведем итоги: в случае, если dwReason равен DLL_PROCESS_ATTACH (при загрузке библиотеки), lpReserved равен NULL для динамической загрузки и non-NULL для статической загрузки.
В случае, если fdwReason равен DLL_PROCESS_DETACH (при выгрузке библиотеки), lpReserved равен NUL при вызове FreeLibrary и при ошибке загрузки DLL, и nonNULL — при окончании процесса. Зачем Microsoft скрывать этот факт? На самом деле, я бы тоже его скрыл :). Подумай сам, сколько возможностей подмены контекста открывается при этом! Что? Ты никогда не слышал о контексте процесса? И системный вызов SetThreadContext тебе тоже ни о чем не говорит? Окей, рассмотрим. Во-первых, контроль над структурой CONTEXT даст нам контроль над регистрами процессора. Все регистры процессора при старте указываются в структуре CONTEXT (смотри описание этой структуры). Это могут быть DEBUG-регистры для контроля над определенным приложением или же установки перехватов вызовов функций.
Или, кстати, установки флага TF в регистре EFLAGS Во-вторых, путем изменения lpReserved->Eip можно изменить точку старта библиотеки. Эта особенность также может быть использована в определении версии ОС, которая используется на целевой машине путем выбора точки входа в зависимости от версии ОС. Незаменимое свойство для обеспечения переносимости кода, кстати.
Аналогичные адреса загрузки dll
И действительно, если ты обращал внимание, такие библио теки как ntdll.dll, kernel32.dll и user32.dll для всех процессов всегда загружаются по одному и тому системному адресу, хотя Microsoft это никак не объясняет. Почему?
Как ты знаешь, указанные библиотеки представляют программисту набор системных функций для работы с системой. К примеру, ntdll.dll является самой важной из юзермодных библиотек. Она представляет собой своеобразную заглушку для вызова системных сервисов. И она должна быть загружена по одному и тому же адресу именно по этой причине. Например, создание любого юзермодного потока всегда происходит через вызов функции ntdll!LdrInitializeThunk. Функция ntdll!KiUserApcDispatcher нужна системе для того, чтобы поставить в очередь исполнение юзермодных асинхронных вызовов. Ядро операционной системы определяет адреса этих функций еще на стадии инициализации системы. И, так как ядро использует скэшированные указатели на эти функции (для быстродействия), ntdll.dll уже не может быть загружена по другим адресам. Kernel32.dll не может быть загружен по различным адресам, потому что большое количество предоставляемых этой библиотекой сервисов используются системой для кросспроцессовых инъекций кода. Например, kernel32.dll ответственна за обработчик событий консоли (что делает команда Ctrl+C в консоли, помнишь?). Так как консоль могут запустить многие программы, адрес обработчика Ctrl+C должен быть одним и тем же. Ну а user32.dll постоянно загружается по одному и тому же адресу по той простой причине, что она предоставляет кучу сервисов, используемых win32k.sys — драйвера, реализующего оконную подсистему Windows.
Указатели на эти функции win32k.sys получает через вызов NtUserInitializeClientPfnArrays во время загрузки.
Однопоточность? Не тут-то было!
Часто ли ты используешь в своих программах отдельные потоки? Если программа простая, и ей не требуется обрабатывать большие массивы данных, вряд ли она для тебя будет многопотоковой. Но это только на первый взгляд.
Потому что многие (если не все) Win32-приложения на самом деле являются многопотоковыми программами, даже если их разработчик утверждает обратное. К примеру, при старте программы сервисом подсистемы CSRSS в программе по умолчанию создается отдельный поток для обработки консольных событий типа Ctrl+C/Ctrl+Break. Во-вторых, большинство Win32-API-функций для выполнения своего кода используют отдельные потоки. Например, вызов WSAAsyncGetHostByName исполь зует синхронный вызов gethostbyname в отдельном потоке, после чего возвращает результаты запрашивающему через оконные сообщения.
Разница между нативными x86версиями библиотек и их WOW64-аналогами
Механизм Wow64 включает в себя полный набор 32-битных системных dll, реализующий Win32 API-функции (для их использования Wow64-программами). Так какова же разница между «нормальными» 32-битными dll и их Wow64-версиями?
На 64-битных версиях Windows разницы между такими библиотеками нет — большинство dll являют собой 32-битные копии с 32-битной версии операционной системы. К примеру, Wow64-библиотека ws2_32.dll на Vista x64 — тот же самый файл, что и 32-битная ws2_32.
dll на Vista x86. Вместе с тем, некоторые dll отличаются очень значительно, к примеру, ntdll.dll. Если мы глянем сквозь призму отладчика на x86 версию ntdll.dll, то легко сможем увидеть, что системный вызов уходит в ядро системы через так называемый SystemCallStub в структуре SharedUserData:
lkd> u ntdll!NtClose
ntdll!ZwClose:
mov eax,30h
mov edx,offset SharedUserData!SystemCallStub
call dword ptr [edx]
ret 4
В Wow64-версии ntdll картина разительно отличается. Вызов системного сервиса происходит через поле по смещению 0xc0 в 32-битной структуре TEB (Thread Environment Block):
lkd> u ntdll!NtClose
ntdll!ZwClose:
mov eax,0Ch
xor ecx,ecx
lea edx,[esp+4]
call dword ptr fs:[0C0h]
ret 4
В свою очередь, раскрываем структуру TEB и там по смещению 0xc0 видим поле, помеченное как “WOW32Reserved”:
lkd> dt ntdll!_TEB
+0x000 NtTib : _NT_TIB
[skip...]
+0x0c0 WOW32Reserved : Ptr32 Void
Кстати, в качестве лирического отступления от темы хочу заметить, что если ты планируешь использовать 32-битные программы под Wow64, будь очень внимателен при использовании таких функций как GetThreadContext/SetThreadContext, и вот почему. Данные функции требуют дополнительных привилегий при исполнении в контексте Wow64. В частности, им нужен доступ к данным THREAD_QUERY_INFORMATION.
12 способов завершить процесс
Чтобы ты всегда мог выйти победителем из социалистического соревнования на тему «Кто знает больше способов грохнуть процесс», проведенного в кругу друзей, любимый журнал заботливо подгоняет тебе целых 12 методов:
- Использовать функции TerminateProcess или NtTerminateProcess — понятно без лишних слов, правда, они всегда перехватываются аверами для своей защиты;
- Использовать CreateRemoteThread с вызовом ExitProcess. Для этого тебе нужно будет найти адрес ExitProcess внутри того процесса, который ты хочешь завершить;
- Использовать комбинацию NtQuerySystemInformation или toolhelp32 с вызовом TerminateThread or NtTerminateThread. Все предельно просто — находишь все потоки искомого процесса и завершаешь их вызовом TerminateThread (NtTerminateThread);
- Вызвать NtQuerySystemInformation или toolhelp32, после чего вызовом SetThreadContext установить регистр EIP так, чтобы он указывал на ExitProcess;
- В цикле от 0 до 4096 вызвать функцию DuplicateHandle с параметрами TargetProcess и TargetProcessHandle равными NULL, а Options равным 0x1. Это закроет если не все, то почти все хендлы открытого процесса. Что интересно — этот метод прекрасно действует против сложных программ и систем, типа антивирусов, однако не сможет грохнуть notepad.exe;
- Довольно громоздкий способ — можно вызвать последовательно CreateJobObject, AssignProcessToJobObject и TerminateJobObject;
- Сложный способ, больше известный в среде дебаггеров — вызываем последовательно NtCreateDebugObject для процесса, затем NtDebugActiveProcess, после чего закрываем хендл дебаг-объекта (читай — процесса) вызовом CloseHandle;
- Оригинальный способ — последовательно для всего региона памяти процесса вызываем VirtualQueryEx с параметром PAGE_NOACCESS и VirtualProtectEx. Процесс тихо умрет, когда все страницы памяти станут недоступными;
- Топорный способ — открываем память процесса VirtualQueryEx, после чего вызовом WriteProcessMemory начинаем писать в память процесса всякую нечитаемую фигню;
- Еще один оригинальный способ — до посинения вызывать VirtualQueryEx. Когда кончится память под выделение, процесс умрет сам;
- Ядерная функция — PsTerminateProcess (PspTerminateProcess). Так как ядром она не экспортируется, вызвать ее можно только путем сканирования ядра на предмет определенной сигнатуры;
- Еще одна неэкспортируемая фукнция — PspTerminateThreadByPointer. Ищется в ядре аналогичным образом, путем сканирования памяти.
Кстати, код, реализующий поиск и перехват PspTerminateThreadByPointer для защиты твоего процесса от убийства таким способом, ты сможешь найти на диске.
Заключение
Читать доки, бесспорно, очень полезно. Поскольку они — рулез. Однако практика показывает, что самые вкусности и сочные куски ОС часто бывают недокументированными, и разработчики Windows очень неохотно раскрывают нам эти секреты. Но все тайное всегда становится явным. Так что дерзай! Удачного компилирования, и да пребудет с тобой Сила!
Links
Для более конкретного изучения внутренностей ОС Windows обычных форумов недостаточно. Очень часто золотые крупинки можно отыскать в блогах системных программистов, таких как www.alex-ionescu.com или http://j00ru.vexillium.org.