Этот таинственный ассемблер
Ну вот, к этому моменту все читатели уже разбежались (или уснули) :end:, так что самое время пускать в ход тяжёлую артиллерию. Держитесь крепче: эту программу мы напишем на чистом ассемблере! Причём сделаем её универсальной – достаточно поменять пару строк, и она будет запускать программу X, отслеживать обращения к файлу Y и перенаправлять их на файл Z.
Вначале немного об ассемблере. Не бойтесь – в нём нет ничего страшного. Современный ассемблер – это уже не тот монстр, который ещё 10 лет назад был кошмаром любого программиста. [Одна линковка чего стоила, с бесконечным подбором многочисленных ключей…] Теперь это – довольно простой язык, во всяком случае, не сложнее Си. Более того: ассемблер – самый доступный язык программирования. Достаточно закачать 780Кб (бесплатный пакет!) – и у вас в руках будет средство, способное создавать программы для DOS/Windows/Linux/BeOS/MeOS, obj-файлы, драйвера всех видов и под любые x86-процессоры (16/32/64-разрядные); всякие специфические штучки (бут-сектора, программы BIOS и пр.). Если же у кого-то проблемы с трафиком, можно закачать уменьшенный пакет (150Кб, без редактора – только компилятор и набор включаемых модулей).
Почему же asm’ом так редко пользуются? Первая и основная причина – практически полное отсутствие документации. Ну вот не привлекает он почему-то внимания авторов книг серии «для чайников». Если в комплекте со всеми остальными языками и средами разработки идёт здоровенный HELP (который подчас весит больше всего остального), то для asm’а ничего такого не предусмотрено. Согласитесь, что «методом тыка» изучить даже самый простой язык невозможно. В сети нормальной инфы тоже нет – сведения приходится собирать буквально по крупицам. Их основным источникам служат всё те же Microsoft SDK/DDK, AMD Architecture Programmer’s manual (вроде бы есть ещё аналогичный мануал от Intel, но я его не нашёл) и, конечно, ассемблерные исходники.
Ещё одна проблема – чуть повышенное время разработки (написание программы на asm’е отнимает примерно на 10-15% больше времени, чем на Си). Впрочем, эти недостатки окупаются исключительной миниатюрностью программ: простейшие «безоконные» программы занимают около 2Кб, а программы, имеющие собственное окошко – 4Кб. Разумеется, это минимальные цифры – чем больше функциональность, тем больше и вес. Но всё равно он будет значительно меньшим, чем у Delphi-приложений. Более того: даже эти миниатюрные программы легко сжимаются архиваторами (в zip-архиве - примерно вдвое).
Так что открываем каталог ExMod и смотрим…
А там есть целых 3 файла. Причём 2 из них стандартные:
* standart.inc – набор макросов, упрощающих программирование. Их я написал уже давно и теперь всё время пользуюсь (вроде как собственная библиотека).
* debug.inc – файл, содержащий описания констант и структур Debug API. Представляет собой конверсию сишных SDK/DDK-включаемых файлов под ASM.
* warmod.asm – собственно программа.
Для компиляции потребуется ассемблер fasm версии не ниже 1.65 (сейчас доступна 1.65.17 – новые версии выходят раз в 2-3 месяца).
Ну ладно. Прежде чем лезть в дебри ассемблерного кода, разберём собственно алгоритм – как можно заставить War заглотить «не тот» архив. Всё просто: прежде чем работать с файлом, его нужно открыть. War открывает файлы с помощью функции CreateFileA. Всё, что нам нужно сделать – отследить вызов этой функции, проверить её параметры, и если имя открываемого файла равно war3patch.mpq, заменить его на war3mod.mpq. И всё! Теперь о том, как отследить вызов нужной функции. Самый простой метод – сплайсинг, но он не годится для локального перехвата (т.е. сплайсингом мы перехватим вызов функции ВСЕМИ процессами Windows, что нам совершенно не нужно). Поэтому воспользуемся другим способом. Он тоже довольно прост: ставим точку останова на функцию, и когда она сработает – смотрим, что там с параметрами. Как уже говорилось ранее, точки останова должны ставиться на каждый поток индивидуально, поэтому ничего лишнего мы не затронем.
Тут, правда, есть одна проблема: в предыдущем примере точки останова удалялись нами сразу после их срабатывания. Здесь так поступить нельзя – точка должна висеть всё время работы War’а, т.к. он в любой момент может «связаться» с MPQ. Поэтому нам придётся заставлять War «проскакивать» точки останова (т.е. заставить его всё-таки выполнить функцию с изменёнными параметрами, несмотря на то, что точка по-прежнему стоит). Делается это так:
1. Убираем (временно) сработавшую точку останова;
2. Устанавливаем в контексте потока флаг TF;
3. После этого, когда то место, где стояла точка останова, будет пройдено, возникнет исключение EXCEPTION_SINGLE_STEP, и мы возвращаем точку останова на её законное место.
Вот. Ну что ж, приступим…
Ну, любая программа начинается с заголовка, и ассемблерная – не исключение. В заголовке указывается, под какую ОС мы будем компилировать программу, под какой процессор и что это вообще за программа (библиотека, драйвер, линкуемый файл, простой exe-файл и т.д.). Там же указывается точка входа (т.е. позиция, с которой начнётся выполнение программы).
Далее подключаются необходимые файлы (директивой include). Напоминает Си, не правда ли?
В самом конце программы я объявляю все необходимые переменные (хотя их можно объявить в любом месте программы). ASM может работать с виртуальными переменными (память под них выделяется динамически), чем я и пользуюсь:
virtual at ebp
C_1: ;для вычисления размеров
pi PROCESS_INFORMATION ;информация о процессе
stui STARTUPINFO ;стартовая информация
de DEBUG_EVENT ;отладочная информация
cont CONTEXT ;контекс потока
V_SIZE = $-C_1 ;размер блока виртуальных переменных
end virtual
Такой приём позволяет сэкономить на размере экзешника, хотя работать с виртуальными переменными чуть сложнее, чем с обычными.
Итак, в начале программы провожу всевозможную инициализацию. В частности, помещаю 0 в ebx (зачем – объясню позже), выделяю память под виртуальные переменные и очищаю её (забиваю нулями).
;1. Всякая инициализация
xor ebx,ebx ;помещаем 0 в ebx
sub esp,V_SIZE ;выделяем память
mov ebp,esp
xor eax,eax ;для очистки блока
mov edi,ebp
mov ecx,V_SIZE/4 ;кол-во двойных слов
rep stosd ;очистка
Теперь нужно выделить память под динамический массив, где будут храниться хэндлы потоков (вам это знакомо по предыдущим примерам):
invoke HeapAlloc,,HEAP_ZERO_MEMORY,4096
mov [hWarThreads],eax ;выделить память под массив
Следующее наше действие – получить адрес функции CreateFileA, чтобы после запуска War’а спокойно поставить туда точку останова. Найденный адрес сохраняется в переменной dwEntry:
;2. Получим адрес точки входа функции CreateFileA
invoke GetProcAddress,,szCrFile
mov [dwEntry],eax ;сохранить полученное
Всё, что начинается с префикса “sz” – строки (имена). Я их все вынес в таблицу строк – в конце файла. Можно было бы подставлять и непосредственно строки, но использование таблицы строк уменьшает размер экзешника и увеличивает скорость его работы (ещё один плюс – все строки собраны в кучу, в случае чего их не надо разыскивать по всему коду).
Теперь – пускаем War. Это осуществляется всё той же функцией CreateProcess:
;3. Пускаем War
mov [stui.cb],sizeof.STARTUPINFO ;размер структуры
mov [stui.dwFlags],STARTF_USESHOWWINDOW
mov [stui.wShowWindow],SW_SHOWNORMAL
lea eax,[pi]
push eax
lea eax,[stui]
push eax
invoke CreateProcess,szName,ebx,ebx,ebx,\
ebx,DEBUG_PROCESS or DEBUG_ONLY_THIS_PROCESS,\
ebx,ebx
Как видим, виртуальные переменные нельзя передавать непосредственно в качестве параметров функции, это делается посредством команд lea/push. Как вы помните, в ebx ещё с начала программы лежит 0. А это число очень часто используется в качестве параметров функции. Передача непосредственно нуля требует 2 байт кода, а передача содержимого регистра (в котором лежит тот же ноль) – только одного байта. Т.е. на вызове CreateProcess мы сэкономили 6 байт! Я часто пользуюсь таким трюком, благо это несложно.
Итак, War запущен. Теперь начинаем обрабатывать события:
lea eax,[de]
invoke WaitForDebugEvent,eax,INFINITE ;ждать события отладки
test eax,eax
jz l_exit ;ошибка
Как вы помните, de – тоже виртуальная переменная, передаётся через eax (по lea). Как известно, в случае какой-либо ошибки WaitForDebugEvent возвращает 0, чем мы и пользуемся (выходим из цикла. test/jz – укороченная разновидность IF, занимает на 3 байта меньше).
Далее идёт анализ событий. Прежде всего, отслеживается создание потоков. Хэндлы всех созданных потоков собираются в массиве hWarThreads:
.if [de.dwDebugEventCode]=CREATE_PROCESS_DEBUG_EVENT
mov edx,[de.CreateProcessInfo.hThread] ;хэндл потока (параметр)
call AddThread ;добавить поток в список
.endif ;of CREATE_PROCESS_DEBUG_EVENT
.if [de.dwDebugEventCode]=CREATE_THREAD_DEBUG_EVENT
mov edx,[de.CreateThread.hThread] ;хэндл потока
call AddThread ;добавить поток в список
.endif ;of CREATE_THREAD_DEBUG_EVENT
Здесь AddThread – процедура, добавляющая хэндл потока в массив и устанавливающая в контексте этого потока точку останова. На входе в процедуру в edx должен лежать хэндл потока для добавления:
AddThread: ;процедура
inc [dwCountOfThreads] ;увеличить кол-во потоков
mov eax,[dwCountOfThreads]
mov ecx,[de.dwThreadId] ;сохраняем ID потока
push edi
mov edi,[hWarThreads]
mov [edi+eax*8],ecx
mov [edi+eax*8+4],edx ;сохраняем хэндл потока
pop edi
SetBP: ;разделяемая процедура (edx - хэндл)
mov [cont.ContextFlags],CONTEXT_DEBUG_REGISTERS
mov [cont.iDr7],3 ;точка останова на выполнение
mov eax,[dwEntry]
mov [cont.iDr0],eax ;адрес точки останова
lea eax,[cont]
invoke SetThreadContext,edx,eax ;установить
ret
Интересная особенность ассемблера – «короткость» многих инструкций. Поэтому ассемблерные программы выглядят довольно оригинально – «столбиком». Если поток завершился, его хэндл нужно удалить из списка:
.if [de.dwDebugEventCode]=EXIT_THREAD_DEBUG_EVENT
;удаляем поток из списка:
call FindThreadHandle
;поток найден. Удаляем из массива...
mov edx,[dwCountOfThreads] ;номер последнего элемента
mov ecx,[hWarThreads] ;адрес массива
push dword [ecx+edx*8]
pop dword [eax]
push dword [ecx+edx*8+4]
pop dword [eax+4]
dec [dwCountOfThreads] ;уменьшить кол-во потоков
.endif ;of EXIT_THREAD_DEBUG_EVENT
Манипуляции push/pop обеспечивают удаление хэндла, затем счётчик потоков уменьшается на 1. Как вы уже догадались, FindThreadHandle – функция, которая ищет хэндл в списке (для последующего удаления). Она находится в начале кода:
FindThreadHandle:
mov eax,[hWarThreads] ;адрес массива
mov edx,[de.dwThreadId] ;ID потока для сравнения
;ищем нужный поток (по хэндлу)
.for ecx=[dwCountOfThreads],ecx>0,ecx--,eax+=8
.exitf edx=[eax] ;выход: нашли поток!
.endf
ret
Цикл for – наиболее оригинальный фрагмент всей функции (хотя те, кто программирует на Си, к такому уже привыкли). На Delphi этот цикл можно перевести как
for ecx:=dwCountOfThreads downto 0 do begin
if edx=eax^ then exit;
eax:=eax+8;
end;
Согласитесь, что asm-версия гораздо нагляднее (Си пользуется аналогичной конструкцией).
Теперь анализируем событие типа EXCEPTION_DEBUG_EVENT. Прежде всего, если это исключение типа EXCEPTION_BREAKPOINT, то продолжить выполнение процесса (используя флаг DBG_CONTINUE):
;проверим, какое возникло исключение:
.if [de.Exception.pExceptionRecord.ExceptionCode]=EXCEPTION_BREAKPOINT
invoke ContinueDebugEvent,[de.dwProcessId],[de.dwThreadId],DBG_CONTINUE
jmp l_loop ;continue
.endif ;of EXCEPTION_BREAKPOINT
Теперь обработаем исключение типа EXCEPTION_SINGLE_STEP – его возникновение означает срабатывание точки останова. Прежде всего получим хэндл потока, вызвавшего исключение:
;получим хэндл потока, вызвавшего исключение
call FindThreadHandle ;найти хэндл потока
mov esi,[eax+4] ;считываем найденный хэндл
Затем проверим, что послужило причиной исключения. Для этого мы читаем содержимое контекста потока. И если там нет точки останова – значит, исключение пришло от флага TF (прохождение того места, где эта точка была). И точку надо установить заново:
;определим причину исключения
mov [cont.ContextFlags],CONTEXT_DEBUG_REGISTERS
lea eax,[cont]
invoke GetThreadContext,esi,eax ;получаем содержимое регистров
.if [cont.iDr0]=0
mov edx,esi ;хэндл потока
call SetBP ;установить точку останова (заново)
jmp l_continue ;продолжить
.endif ;of Dr0=0
В противном случае исключение пришло от точки останова. А значит, War пытается открыть некий файл. Прежде всего, снимем эту точку останова и установим флаг TF (трассировочный, для последующей установки точки):
;установим флаг трассировки и снимем точку останова
mov [cont.iDr0],ebx ;0
lea eax,[cont]
invoke SetThreadContext,esi,eax ;установить
mov [cont.ContextFlags],CONTEXT_CONTROL
lea eax,[cont]
invoke GetThreadContext,esi,eax
or [cont.regFlag],FLAG_TF ;TF
Теперь начинаем анализировать параметры функции. Вначале читаем указатель на имя файла, а затем и само имя:
;проверим параметры функции
mov eax,[cont.regEsp] ;считать содержимое esp
add eax,4 ;получить указатель на имя файла
invoke ReadProcessMemory,[pi.hProcess],eax,dwAddr,4,ebx
invoke ReadProcessMemory,[pi.hProcess],[dwAddr],uBuf,14,ebx
А теперь проверим, не war3patch.mpq ли это. Для сравнения строк используем lstrcmpi, которая сравнивает строки без учёта регистра символов (Си тоже так умеет). И если это – war3patch.mpq, записываем туда другое имя:
invoke lstrcmpi,uBuf,szFile ;сравним строки
.ifz eax ;это - war3patch.mpq
invoke WriteProcessMemory,[pi.hProcess],[dwAddr],szNewFile,N_SIZE,ebx
.endif
Затем просто доустанавливаем контекст.
Вот, в принципе, и всё – далее идут лишь всякие «обёртки» цикла и завершение программы. Как видите, всё не так уж сложно.
Компилируем программу… И видим, что её размер равен 2Кб! (Кстати, размер программы всегда округляется asm’ом до величины, кратной 512 байтам).