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

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

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


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

8f60be131c69449ebc6430b23ede4193.png

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

6328e312e64e4addaf57000569905e5f.png

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

a1c8918439264744a83369b4bf5ee29a.png

Теперь перейдём по адресу 0×00AF3A64, который лежит на вершине стека и должен указывать на код сразу за вызовом 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, который ожидает события от созданного пайпа.

b5280adddbbe4a4f92408fb90c9fbef7.png

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

Легко заметить, что после того, как WaitForMultipleObjects вернёт управление, будет создан новый поток, который вероятно и обрабатывает события на свежеподключенном пайпе. Перейдём по адресу 0×00CC5080:

c1ae4763a50e46118fa4684882244cbf.png

Вот и искомая функция 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 байт. Теперь можно переходить к разбору того, что же эти байты означают.

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

aa857d3007464840a877dd419505f06f.png

Вот примерно так выглядит то, что приходит в программу из игры. Сразу бросаются в глаза строки 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 будет произведена с помощью вызова одной из этих функций и, если перехватить вызов — можно, побродив по стеку, выйти на сам загрузчик.

48073a57d702478ab8ad52d166383970.png

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

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


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

be3d2aa0f29e4a79bfc27470e83a6c36.png

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

3e685a8bf0e2406a9140a7eec5def594.png

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

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 не давала мне покоя. Поэтому я решил глянуть, куда же все-таки вернётся управление после вызова. Идём по адресу 0×754D3677 и видим:

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.

© Habrahabr.ru