Как стать автором
Обновить

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

Время на прочтение11 мин
Количество просмотров6.6K
Так как тема реверс инжиниринга довольно популярна на хабре, решил поделиться своими наработками по этой теме. Мне, как и многим любителям визуальных новелл, знакома такая программа, как 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.
Теги:
Хабы:
Всего голосов 13: ↑12 и ↓1+11
Комментарии1

Публикации