Так как тема реверс инжиниринга довольно популярна на хабре, я решил поделиться своими наработками по этой теме.
Мне, как и многим любителям визуальных новелл знакома такая программа как 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.
/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
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/
Добавить комментарий