WarCraft 3: Часть II: Затолкать дрянь

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-архиве - примерно вдвое).
Так что открываем каталог 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 «проскакивать» точки останова (т.е. заставить его всё-таки выполнить функцию с изменёнными параметрами, несмотря на то, что точка по-прежнему стоит). Делается это так:
  • Убираем (временно) сработавшую точку останова;
  • Устанавливаем в контексте потока флаг 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 (зачем – объясню позже), выделяю память под виртуальные переменные и очищаю её (забиваю нулями).

 ;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:

 ;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 байтам).
Так, вижу, я остался в гордом одиночестве – все читатели уже потерялись. Что ж, пойду и я…

Просмотров: 6 164

Vampirrr #1 - 7 лет назад 1
Обожаю ассемблей, будь он неладен..А так тема тру, пищы исчо xD
XenusTEHG #2 - 6 лет назад 2
Прохожу в колледже практику по ассемблеру, по нему же экзамен будет.
Ненавижу этот язык, чтоб его....
Хотя, с этим языком есть один нюанс:
Язык предназначен для управления стеком памяти, который организован методом: Первый вошёл- Первый вышел.
Выполнение функции обеспечивает оператор Invoke. Умные люди уже догадались, что есть один Ассемблеро-подобный гер =)