Реверс AGTH для воссоздания альтернативного GUI

от автора

Так как тема реверс инжиниринга довольно популярна на хабре, я решил поделиться своими наработками по этой теме.
Мне, как и многим любителям визуальных новелл знакома такая программа как AGTH (Anime-Game-Text-Hooker). Она позволяет извлекать текст из новелл для последующего перевода(большинство игр – японские). Разработка этой программы, судя по всему, была прекращена ещё в 2011м году, исходников найти не удалось, а так как душа хотела дополнительных фич, было принято решение отреверсить эту программу и на основе полученных данных воссоздать альтернативную оболочку со всеми недостающими мне функциями.

Оригинальная программа состоит из двух частей – исполняемого файла и модуля перехвата выполненного в виде динамической библиотеки. Эту библиотеку программа внедряет в процесс игры и с её помощью получает оттуда текст.
Реверсить и переписывать я буду лишь исполняемый файл, а модуль перехвата оставлю оригинальный. На это есть несколько причин. Помимо очевидной сложности модуля и присущей мне лени, необходимо обеспечить совместимость моей разработки с так называемыми H-кодами. H-код — это набор данных нужный перехватчику для корректной установки хука в случае, когда дефолтные хуки неэффективны. Он содержит в себе адреса памяти, номера регистров и прочую информацию о местонахождении текста в игре. Для каждой отдельной игры этот код уникален и найден энтузиастами. Поэтому написать свой модуль так сказать «по мотивам» — не выйдет. Нужно будет обеспечить полную совместимость по этим кодам, а это совсем другой уровень сложности. Да и никаких дополнительных преимуществ это не даст.

Разбор протокола общения модуля перехвата и AGTH

Очевидно, что модуль перехвата в игре и AGTH как-то взаимодействуют между собой, и для написания альтернативной оболочки нужно узнать как. Способов передать данные от одной программы к другой довольно много, начиная от оконных сообщений и заканчивая сокетами. Какой же способ использован на самом деле я узнал случайно. Просто зашел в свойства процесса agth.exe через Process Explorer и решил посмотреть, какие строки содержит эта программа.

В глаза сразу бросилась строка "\\.\pipe\agth" — так указывается именованный канал, а значит можно предположить, что AGTH использует пайпы для общения с игрой. Теперь у нас есть направление, в котором можно начинать поиски. Для отладки я буду использовать любимый многими отладчик OllyDbg.
Загрузим AGTH в «Олю» и сразу поставим бряки на CreateNamedPipe* функции внутри модуля kernel32. Один из этих бряков должен сработать как только программа попытается создать именованный канал и из этой точки можно будет добраться до кода который с этими пайпами работает.

Продолжим выполнение и со второго срабатывания бряка попадаем в нужное место. О том, что это место нужное говорит нам наличие строки "\\.\pipe\agth" на стеке.

Теперь перейдём по адресу 0x00AF3A64, который лежит на вершине стека и должен указывать на код сразу за вызовом CreateNamedPipeW.

001B3A43   > 56             PUSH ESI ; 0x0 00AF3A44   . 6A 00          PUSH 0 00AF3A46   . 68 00000200    PUSH 20000 00AF3A4B   . 6A 00          PUSH 0 00AF3A4D   . 68 FF000000    PUSH 0FF 00AF3A52   . 6A 06          PUSH 6 00AF3A54   . 68 01000840    PUSH 40080001 00AF3A59   . 68 A026AF00    PUSH agth.00AF26A0                       ;  UNICODE "\\.\pipe\agth" 00AF3A5E   . FF15 4010AF00  CALL DWORD PTR DS:[<&KERNEL32.CreateName>;  kernel32.CreateNamedPipeW 00AF3A64   . 8BF8           MOV EDI,EAX 00AF3A66   . EB 03          JMP SHORT agth.00AF3A6B 

Тут уже можно разобрать с какими параметрами наш пайп создаётся, а именно:

CreateNamedPipeW("\\.\pipe\agth", 40080001, 6, 0xFF, 0, 0x20000, 0, NULL);

Воспользуемся документацией и развернём магические числа в именованные константы. Получится так:

CreateNamedPipeW("\\.\pipe\agth", PIPE_ACCESS_INBOUND | FILE_FLAG_OVERLAPPED | FILE_FLAG_FIRST_PIPE_INSTANCE, PIPE_WAIT | PIPE_READMODE_MESSAGE | PIPE_TYPE_MESSAGE, 0xFF, 0, 0x20000, 0, NULL);

Пробежав по коду чуть ниже можно встретить вызов функций ConnectNamedPipe и WaitForMultipleObjects , который ожидает события от созданного пайпа.

Хорошо, теперь нужно узнать, как происходит чтение данных, а точнее каков размер блока данных, передаваемого от игры к приложению. О том, что данные передаются блоками, а не непрерывным потоком байт, говорит наличие флага PIPE_TYPE_MESSAGE используемое при создании канала.
Легко заметить, что после того, как WaitForMultipleObjects вернёт управление, будет создан новый поток, который вероятно и обрабатывает события на свежеподключенном пайпе. Перейдём по адресу 0x00CC5080:

Вот и искомая функция ReadFile, которая вызывается с параметрами:

0291D9B4   00000104  |hFile = 00000104 (window) 0291D9B8   0291DA78  |Buffer = 0291DA78 0291D9BC   00001FE8  |BytesToRead = 1FE8 (8168.) 0291D9C0   0291DA14  |pBytesRead = 0291DA14 0291D9C4   004C4168  \pOverlapped = 004C4168 

Их я достал со стека в тот момент, когда сработал бряк, заблаговременно установленный на вызов ReadFile. В общем-то, нас интересует лишь параметр BytesToRead, который равен 8168-ми байтам. Вероятно – это и есть размер структуры с текстом, которую передаёт игра в программу.

В итоге собрано достаточно информации о том, как происходит взаимодействие с игрой: AGTH реализует пайп-сервер, который принимает данные кусками по 8168 байт. Теперь можно переходить к разбору того, что же эти байты означают.

Разбор формата данных я решил сделать уже внутри своей программы. В ней я реализовал собственный сервер, воспользовавшись данными полученными ранее, и с его помощью получал сообщения от игры. Очень удобно – можно завести структуру нужного размера и вычитывать данные прямо в неё. По ходу разбора того, что значит та или иная группа байт, эту структуру можно модифицировать и в конце получить полное описание всех полей.

Вот примерно так выглядит то, что приходит в программу из игры. Сразу бросаются в глаза строки UserHookQ и K.o.t.a.r.o.u. Первая — имя функции, которое отображается в оригинальной программе, второе — текст из игры в кодировке UTF-16. Также замечено число 7 (синее выделение) которое, как оказалось, всегда равно количеству символов строки игрового текста. Перебирая разные наборы данных выяснилось, что имя функции — это null-terminated строка с максимальной длинной в 24 символа. То есть, в случае со скриншотом выше, все байты между зелёным и синим выделением — просто мусор. Осталось ещё 16 байт данных в начале структуры. Первые две переменные определить было легко — это Context и Subcontext, которые также можно видеть в окне оригинальной программы. Третий параметр найти было чуть сложнее — он всегда имел небольшие значения и менялся только при перезапуске игры. Им оказался ProcessID игры. Последний из четвёрки менялся постоянно и имел достаточно большие значения. Единственной зацепкой было то, что это значение всегда увеличивалось со временем и никогда не уменьшалось. Это и было временем, точнее результатом вызова функции GetTickCount.

В итоге получилась такая структура:

  TAGTHRcPckt = packed record // SizeOf = 8168 bytes     Context: Cardinal;     Subcontext: Cardinal;     ProcessID: Cardinal;     UpTime: Cardinal;     TextLength: Cardinal;     HookName: array [0 .. 23] of ansichar;     Text: array [0 .. 4061] of widechar;   end;

С коммуникацией между приложением и игрой разобрались, теперь нужно узнать каким образом модуль перехвата текста попадает в игру и получает информацию о том, куда и как устанавливать хуки.

Исследование загрузчика

Запустим игру (или какое угодно другое приложение), подождём окончательной загрузки и прицепимся к ней отладчиком. Далее откроем список модулей, выберем kernel32, и в списке функций поставим брякпоинты на всех функциях, которые начинаются на LoadLibrary*. Это сделано потому, что как не крути, а финальная загрузка dll будет произведена с помощью вызова одной из этих функций и, если перехватить вызов — можно, побродив по стеку, выйти на сам загрузчик.

Продолжим выполнение программы. Затем запустим AGTH и укажем ему процесс игры:

agth /PNИмя_процесса.exe

Тут же сработает отладчик. В моём случае бряк сработал на функции LoadLibraryW.
Посмотрим на стек:

второй сверху это аргумент функции, а вот первый — это адрес возврата и ведёт он куда-то в недра kernel32. Странно, я ожидал увидеть там адрес внедрённого в игру кода загрузчика. Что ж, посмотрим, что лежит рядом аргументом LoadLibraryW. Перейдём по адресу 0x7EF80022 и вот оно!

Это и есть искомый загрузчик, кстати, довольно хитрый: всего 4 команды (начиная с адреса 0x7EF80014 идут данные).

7EF80000   68 1E00F87E      PUSH 7EF8001E               ; UNICODE "0" 7EF80005   68 1400F87E      PUSH 7EF80014               ; UNICODE "AGTH" 7EF8000A   68 121E4D75      PUSH kernel32.LoadLibraryW 7EF8000F  -E9 CE9755F6      JMP kernel32.SetEnvironmentVariableW

Сначала на стек складываются параметры функции SetEnvironmentVariableW(‘AGTH’,’0′), потом — адрес функции LoadLibraryW, который служит адресом возврата для функции SetEnvironmentVariableW, так как вызывается она не через CALL, а с помощью безусловного перехода JMP. «Так вот почему LoadLibraryW был вызван откуда-то из недр kernel32, а не загрузчиком!» — так я подумал. Но мысль о том, что же будет после того как отработает LoadLibrary не давала мне покоя. Поэтому я решил глянуть, куда же все-таки вернётся управление после вызова. Идём по адресу 0x754D3677 и видим:

754D3677   50               PUSH EAX 754D3678   FF15 F0064D75    CALL DWORD PTR DS:[<&ntdll.RtlExitUserThread>]     ; ntdll.RtlExitUserThread

Судя по всему после вызова LoadLibraryW, будет вызван RtlExitUserThread с параметром, который вернёт LoadLibraryW и таким образом удалённый поток успешно завершится. Казалось бы — всё хорошо, но меня не покидала мысль: «А откуда вообще на стеке оказался этот адрес, и где программа достала адрес строки, в которой путь к внедряемой dll лежит? Ведь в коде загрузчика ничего подобного нет!». Выходит кто-то положил эти адреса на стек ещё до того как была вызвана первая инструкция загрузчика. И тут меня осенило: удалённые потоки создаются с помощью функции CreateRemoteThread, а она кроме указателя на функцию принимает ещё и параметр для этой функции. То есть она складывает на стек сначала адрес RtlExitUserThread, чтобы поток, сделав RET, корректно завершился, а потом ещё и переменную — параметр.

Ещё раз вкратце:

  • CreateRemoteThread складывает на стек адрес RtlExitUserThread, путь к dll и запускает загрузчик
  • загрузчик складывает на стек аргументы для SetEnvironmentVariableW, адрес LoadLibraryW и делает безусловный переход на SetEnvironmentVariableW
  • SetEnvironmentVariableW забирает свои аргументы со стека и при возврате из неё поток оказывается в начале LoadLibraryW
  • LoadLibraryW забирает со стека путь к dll и при возврате из неё поток попадает на RtlExitUserThread
  • RtlExitUserThread завершает поток

Кстати, такая игра со стеком, когда функция после RET-а попадает не в вызвавший её код, а в другую функцию, называется техникой возвратно-ориентированного программирования или просто ROP (Return-Oriented Programming).

Хорошо, с внедрением и передачей параметров в целевой процесс разобрались, все параметры передаются через переменную окружения с именем «AGTH». Получается, что в случае написания собственного загрузчика достаточно установить переменную окружения и загрузить dll.

Загрузчик:

// Структура представляющая собой будущий машинный код   TInject = packed record     // code     cmd0: BYTE;     cmd1: BYTE;     cmd1arg: DWORD;     cmd2: BYTE;     cmd2arg: DWORD;     cmd3: WORD;     cmd3arg: DWORD;     cmd4: BYTE;     cmd4arg: DWORD;     cmd5: WORD;     cmd5arg: DWORD;     cmd6: BYTE;     cmd6arg: DWORD;     cmd7: WORD;     cmd7arg: DWORD;     // data     pLoadLibrary: Pointer;     pExitThread: Pointer;     pSetEnvironmentVariableW: Pointer;     ENVName: array [0 .. 4] of WideChar;     ENVValue: array [0 .. MAX_PATH] of WideChar;     LibraryPath: array [0 .. MAX_PATH] of WideChar;   end;  const // бинарное представление ассемблерных команд   PUSH: BYTE = $68;   CALL_DWORD_PTR: WORD = $15FF;   INT3: BYTE = $CC;   NOP: BYTE = $90;  { Внедрение Dll в процесс } class function THooker.InjectDll(Process: DWORD;   ModulePath, HCode: WideString): boolean; var   Memory: Pointer;   CodeBase: DWORD;   BytesWritten: SIZE_T;   ThreadId: DWORD;   hThread: DWORD;   hKernel32: DWORD;   Inject: TInject;    function RebasePtr(ptr: Pointer): DWORD;   // перебазируем локальные указатели на адреса   // в целевом процессе   begin     Result := CodeBase + DWORD(ptr) - DWORD(@Inject);   end;  begin   Result := false;   // выделяем память в целевом процессе   // с атрибутами на чтение запись и выполнение   Memory := VirtualAllocEx(Process, nil, sizeof(Inject), MEM_TOP_DOWN or     MEM_COMMIT, PAGE_EXECUTE_READWRITE);   if Memory = nil then     Exit;    CodeBase := DWORD(Memory);   hKernel32 := GetModuleHandle('kernel32.dll');    // инициализация внедряемого кода:   // структура Inject представляет собой машинный код нашего загрузчика   FillChar(Inject, sizeof(Inject), 0);   with Inject do   begin     // code     cmd0 := NOP;     cmd1 := PUSH;     cmd1arg := RebasePtr(@ENVValue);     cmd2 := PUSH;     cmd2arg := RebasePtr(@ENVName);     cmd3 := CALL_DWORD_PTR;     cmd3arg := RebasePtr(@pSetEnvironmentVariableW);     cmd4 := PUSH;     cmd4arg := RebasePtr(@LibraryPath);     cmd5 := CALL_DWORD_PTR;     cmd5arg := RebasePtr(@pLoadLibrary);     cmd6 := PUSH;     cmd6arg := 0;     cmd7 := CALL_DWORD_PTR;     cmd7arg := RebasePtr(@pExitThread);     // data     // тут происходит магия основанная на том,     // что ImageBase kernel32.dll во всех процессах одинаков     // поэтому не требуется пересчитывать указатели на его функции     // они такие-же как и в нашем процессе     // это справедливо лишь для kernel32.dll только     // и вообще недокументированная особенность     // не делайте так в серьёзных проектах     pLoadLibrary := GetProcAddress(hKernel32, 'LoadLibraryW');     pExitThread := GetProcAddress(hKernel32, 'ExitThread');     pSetEnvironmentVariableW := GetProcAddress(hKernel32,       'SetEnvironmentVariableW');     lstrcpy(@LibraryPath, PWideChar(ModulePath));     lstrcpy(@ENVName, PWideChar('AGTH'));     lstrcpy(@ENVValue, PWideChar(HCode));   end;   // записать машинный код по зарезервированному адресу   WriteProcessMemory(Process, Memory, @Inject, SIZE_T(sizeof(Inject)),     BytesWritten);   // выполнить машинный код   hThread := CreateRemoteThread(Process, nil, 0, Memory, nil, 0, ThreadId);   if hThread = 0 then     Exit;   // подождём пока отработает наш загрузчик   WaitForSingleObject(hThread, INFINITE);   CloseHandle(hThread);   VirtualFreeEx(Process, Memory, 0, MEM_RELEASE);   // надо-надо умываться по утрам и вечерам   Result := true; end;

Теперь нужно разобраться с параметрами, точнее с тем как командная строка программы, через которую задаётся H-код, превращается в значение той самой переменной окружения.
Чтобы постоянно не ковыряться в отладчике была написана библиотека-заглушка единственной функцией которой является чтение и вывод переменной «AGTH» для дальнейшего изучения.

Код заглушки:

library AGTH;  uses windows;  var   buffer: array [0 .. 255] of widechar;  begin   GetEnvironmentVariableW('AGTH', buffer, 256);   MessageBoxW(0, buffer, buffer, 0); end.

Далее, подменив оригинальную dll, я начал перебирать все возможные ключи командной строки и смотреть как они отображаются на переменную окружения. Это оказалось несложно.
Список всех команд можно посмотреть в справке, встроенной в оригинальную программу. Из этих команд меня интересовали только Hook options.

Hook options:

/H[X]{A|B|W|S|Q}[N][data_offset[*drdo]][:sub_offset[*drso]]@addr[:module[:{name|#ordinal}]] - select OK for more help /NC - don't hook child processes /NH - no default hooks /NJ - use thread code page instead of Shift-JIS for non-unicode text (should be specified for capturing non-japanese text) /NS - don't use subcontexts /S[IP_address] - send text to custom computer (default parameter: local computer) /V - process text threads from system contexts /X[sets_mask] - extended sets of hooked functions (default parameter: 1; number of available sets: 2)

Дальше просто вводим случайные параметры командной строки и смотрим, как они влияют на финальный результат.
Например, набор ключей ‘/HQN54@48693e /NH /Slocalhost’ превращается в ’20S0:localhostUQN54@48693e’ и сразу видно, что значения ключей /H и /S передаются как есть. Также было выяснено, что префиксы U и S0: не меняются никогда и исчезают совсем лишь при отсутствии соответствующих ключей /H и /S. Все остальные ключи влияют только на первые два шестнадцатеричных числа. Поиграв с ключами ещё немного выяснилось, что это битовые флаги, где каждый ключ отвечает за установку отдельного бита в байте, который представляют эти два числа.

Получилась табличка:

/nh - 20 - 10 0000 /nc - 10 - 01 0000 /nj - 08 - 00 1000 /x3 - 06 - 00 0110 // комбинация /x2 и /x /x2 - 04 - 00 0100 /x  - 02 - 00 0010 /V  - 01 - 00 0001

Функция преобразования командной строки в H-код

const   PROCESS_SYSTEM_CONTEXT = $01;   HOOK_SET_1 = $02;   HOOK_SET_2 = $04;   USE_THREAD_CODEPAGE = $08;   NO_HOOK_CHILD = $10;   NO_DEF_HOOKS = $20;  class function THooker.GenerateHCode(AGTHcmd: string): string; var   i: Integer;   lcmd, uFlag, sFlag: string;   flags: BYTE; begin   lcmd := lowercase(AGTHcmd);   flags := 0;    if pos('/nh', lcmd) > 0 then     flags := flags or NO_DEF_HOOKS;   if pos('/nc', lcmd) > 0 then     flags := flags or NO_HOOK_CHILD;   if pos('/nj', lcmd) > 0 then     flags := flags or USE_THREAD_CODEPAGE;   if pos('/v', lcmd) > 0 then     flags := flags or PROCESS_SYSTEM_CONTEXT;    if pos('/x3', lcmd) > 0 then     flags := flags or (HOOK_SET_1 or HOOK_SET_2)   else if pos('/x2', lcmd) > 0 then     flags := flags or HOOK_SET_2   else if pos('/x', lcmd) > 0 then     flags := flags or HOOK_SET_1;    // выгребаем все между /h и пробелом и в начало ставим символ U   i := pos('/h', lcmd);   if i > 0 then   begin     uFlag := copy(AGTHcmd, i, length(AGTHcmd) - (i - 1)); // /h -> endstr     delete(uFlag, 1, 2); // del /h     i := pos(' ', uFlag);     if i > 0 then       delete(uFlag, i, length(uFlag) - (i - 1));     uFlag := 'U' + uFlag;   end   else     uFlag := '';    // выгребаем все между /s и пробелом и в начало ставим символы S0:   i := pos('/s', lcmd);   if i > 0 then   begin     sFlag := copy(AGTHcmd, i, length(AGTHcmd) - (i - 1));     delete(sFlag, 1, 2); // del /s     i := pos(' ', sFlag);     if i > 0 then       delete(sFlag, i, length(sFlag) - (i - 1));     sFlag := 'S0:' + sFlag;   end   else     sFlag := '';    Result := IntToHex(flags, 1) + sFlag + uFlag; end;

Таким образом формат параметров для библиотеки удалось разобрать.

Конец

Вот и всё. Дело осталось за малым – реализовать собственный интерфейс и добавить нужных фич. Что и было сделано:

  • с помощью слоистых окон был реализован вывод субтитров поверх игры
  • добавлена интеграция с гуглопереводчиком
  • юзерскрипты на JS для препроцессинга текста перед переводом

Написание остального кода достаточно тривиально поэтому здесь я приводить его не буду, просто оставлю ссылку на Github.

ссылка на оригинал статьи http://habrahabr.ru/post/262311/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *