Добавлен Алексей,
опубликован
WarCraft: Взгляд изнутри
Содержание:
Да, теперь мы напишем программу, при запуске которой War (и WE) вместо файла war3patch.mpq будут грузить War3mod.mpq. Боюсь, правда, что подобный «мод» представляет исключительно теоретический интерес, т.к. мне ещё не встречались случаи замены War’овских MPQ самопальными.
Этот таинственный ассемблер
Ну вот, к этому моменту все читатели уже разбежались (или уснули) :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-архиве - примерно вдвое).
Вначале немного об ассемблере. Не бойтесь – в нём нет ничего страшного. Современный ассемблер – это уже не тот монстр, который ещё 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 из них стандартные:
А там есть целых 3 файла. Причём 2 из них стандартные:
- standart.inc – набор макросов, упрощающих программирование. Их я написал уже давно и теперь всё время пользуюсь (вроде как собственная библиотека).
- debug.inc – файл, содержащий описания констант и структур Debug API. Представляет собой конверсию сишных SDK/DDK-включаемых файлов под ASM.
- warmod.asm – собственно программа.
Ну ладно. Прежде чем лезть в дебри ассемблерного кода, разберём собственно алгоритм – как можно заставить War заглотить «не тот» архив. Всё просто: прежде чем работать с файлом, его нужно открыть. War открывает файлы с помощью функции CreateFileA. Всё, что нам нужно сделать – отследить вызов этой функции, проверить её параметры, и если имя открываемого файла равно war3patch.mpq, заменить его на war3mod.mpq. И всё! Теперь о том, как отследить вызов нужной функции. Самый простой метод – сплайсинг, но он не годится для локального перехвата (т.е. сплайсингом мы перехватим вызов функции ВСЕМИ процессами Windows, что нам совершенно не нужно). Поэтому воспользуемся другим способом. Он тоже довольно прост: ставим точку останова на функцию, и когда она сработает – смотрим, что там с параметрами. Как уже говорилось ранее, точки останова должны ставиться на каждый поток индивидуально, поэтому ничего лишнего мы не затронем.
Тут, правда, есть одна проблема: в предыдущем примере точки останова удалялись нами сразу после их срабатывания. Здесь так поступить нельзя – точка должна висеть всё время работы War’а, т.к. он в любой момент может «связаться» с MPQ. Поэтому нам придётся заставлять War «проскакивать» точки останова (т.е. заставить его всё-таки выполнить функцию с изменёнными параметрами, несмотря на то, что точка по-прежнему стоит). Делается это так:
- Убираем (временно) сработавшую точку останова;
- Устанавливаем в контексте потока флаг TF;
- После этого, когда то место, где стояла точка останова, будет пройдено, возникнет исключение 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 (зачем – объясню позже), выделяю память под виртуальные переменные и очищаю её (забиваю нулями).
Итак, в начале программы провожу всевозможную инициализацию. В частности, помещаю 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,<invoke GetProcessHeap>,HEAP_ZERO_MEMORY,4096
mov [hWarThreads],eax ;выделить память под массив
Следующее наше действие – получить адрес функции CreateFileA, чтобы после запуска War’а спокойно поставить туда точку останова. Найденный адрес сохраняется в переменной dwEntry:
;2. Получим адрес точки входа функции CreateFileA
invoke GetProcAddress,<invoke GetModuleHandle,szKernel32>,szCrFile
mov [dwEntry],eax ;сохранить полученное
Всё, что начинается с префикса “sz” – строки (имена). Я их все вынес в таблицу строк – в конце файла. Можно было бы подставлять и непосредственно строки, но использование таблицы строк уменьшает размер экзешника и увеличивает скорость его работы (ещё один плюс – все строки собраны в кучу, в случае чего их не надо разыскивать по всему коду).
Теперь – пускаем War. Это осуществляется всё той же функцией CreateProcess:
Теперь – пускаем 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 запущен. Теперь начинаем обрабатывать события:
Итак, War запущен. Теперь начинаем обрабатывать события:
lea eax,[de]
invoke WaitForDebugEvent,eax,INFINITE ;ждать события отладки
test eax,eax
jz l_exit ;ошибка
Как вы помните, de – тоже виртуальная переменная, передаётся через eax (по lea). Как известно, в случае какой-либо ошибки WaitForDebugEvent возвращает 0, чем мы и пользуемся (выходим из цикла. test/jz – укороченная разновидность IF, занимает на 3 байта меньше).
Далее идёт анализ событий. Прежде всего, отслеживается создание потоков. Хэндлы всех созданных потоков собираются в массиве hWarThreads:
Далее идёт анализ событий. Прежде всего, отслеживается создание потоков. Хэндлы всех созданных потоков собираются в массиве 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):
Теперь анализируем событие типа 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 байтам).
Вот, в принципе, и всё – далее идут лишь всякие «обёртки» цикла и завершение программы. Как видите, всё не так уж сложно.
Компилируем программу… И видим, что её размер равен 2Кб! (Кстати, размер программы всегда округляется asm’ом до величины, кратной 512 байтам).
Так, вижу, я остался в гордом одиночестве – все читатели уже потерялись. Что ж, пойду и я…
Содержание
`
ОЖИДАНИЕ РЕКЛАМЫ...
Чтобы оставить комментарий, пожалуйста, войдите на сайт.
Ненавижу этот язык, чтоб его....
Хотя, с этим языком есть один нюанс:
Выполнение функции обеспечивает оператор Invoke. Умные люди уже догадались, что есть один Ассемблеро-подобный гер =)