Пролог
Все началось год назад, когда один из моих товарищей с форума T предложил переписать известную всему читерскому миру программу l2phx за авторством многоуважаемого xkor`а.
Сам l2phx (l2 packet hack, пакетник, хлапа) представляет из себя сниффер входящих и исходящих пакетов (все реализовано через LSP) клиента lineage 2 (существуют версии для других mmorpg), с возможностью отправки/подмены отдельных пакетов. Xkor постарался как следуют: реализовал методы обхода шифрации, красивый gui и тп. Но злобным админам фришек такое приложение не понравилось: оно существенно убивало их доход на старте очередных однодневок. Да-да, были времена когда любой нонейм мог зайти на любой сервер и устроить полную вакханалию этим инструментом. Тогда же и появились всяческие коммерческие защиты, которые
Тогда я отнесся к этой затеи не очень серьезно: написал модуль перехвата пакетов клиент -> сервер и забросил. Почему? Потому. Но буквально 3 дня назад я решил возобновить работу над этим проектом и опубликовать данную статью. Почему? Комьюнити читеров l2 на данный момент мертво. Все баги и отмывы к ним находятся в руках 10 человек, которые общаются между собой в скайпе и на форуме T. И я тоже решил уйти. А если уходить, то лишь красиво)) Два года назад я мечтал о работающем пакетнике, а сегодня он мне не нужен.
Дисклеймер
Перехват пакетов сервер > клиент
Все пакеты, которые клиент получает от сервера, в конечном итоге можно отловить по вызову экспортируемой
UNetworkHandler::AddNetworkQueue
внутри engine.dll:Она представляет из себя обертку, внутри которой идет джамп на оригинальную функцию:
Совершенно очевидно, что где-то здесь хитрая защита S и расшифровывает пакеты, которые шифровались ей дополнительно на сервере. Если посмотреть на то, как этот же код выглядит в памяти, то мы увидим следующее:
Как банально, это самый обычный jmp near на некий обработчик. Сам обработчик нам не интересен, пусть работает себе. Мы же просто поставим хук после этого хука и получим пакет в расшифрованном виде. Тут возникла первая проблема. Методом научного тыка было выявлено, что функции типа
VirtualProtect
и VirtualAlloc
отрабатывают с ошибкой, а без них в защищенную память луче не лезть. Почему это происходит? Я так и не выяснил, не было времени. Но могу сказать, что защита S перехватывает NtProtectVirtualMemory
и что-то там делает. Тут я начал строить хитроумный план по обману защиты, но моя лень взяла верх и я тупо сделал так:HANDLE hMain = OpenProcess(PROCESS_VM_OPERATION, FALSE, GetCurrentProcessId());
VirtualProtectEx(hMain, ... );
Конечно, не красиво, если учесть, что мы находимся внутри процесса (забыл упомянуть, мы пишем именно длл); но это работает. Возвращаемся к хуку… и всплывает вторая проблема: защита проверяет первые 10-20 байт этой функции. Это выясняется сразу, тк вылезает наг окошко, где нас ругают матом. Что делать? Правильно, поставим хук дальше. Я выбрал смещение 0x14 (см картинку выше). jmp near занимает 5 байт, те мы перезапишем
add esi, 0x3c
push 0x1
На
jmp ...
Не стоит это забывать, тк в конце нашего обработчика придется их восстанавливать. К слову. хук можно поставить внутри импортируемой
EnterCriticalSection
или где либо еще. Идем далее. Структуру пакета, который передается в функцию AddNetworkQueue
, еще в далеком 2010 году, опубликовал многоуважаемый GoldFinch:struct NetworkPacket
{
unsigned char id, _padding1, exid, _padding2;
unsigned short size, _padding3;
unsigned char* data;
}
Нас интересуют поля
id
и data
. А так же содержимое регистра ecx
. Почему ecx
? Все просто: мы имеем дело с соглашением __thiscall
и для вызова любой функции класса UNetworkHandler
мы обязаны иметь при себе указатель на наш объект. Он передается именно в ecx
. Зачем нам что-то вызывать? Далее вы все поймете, а пока я привожу готовый код:BYTE *AddNetworkQueue = (BYTE *)GetProcAddress(hEngine, "?AddNetworkQueue@UNetworkHandler@@UAEHPAUNetworkPacket@@@Z");
AddNetworkQueue += *(DWORD *)(AddNetworkQueue + 1) + 5;
retAddr_AddNetworkQueue = (DWORD)AddNetworkQueue + 0x19;
trmpAddr = (DWORD)wrapper_AddNetworkQueue - ((DWORD)AddNetworkQueue + 0x14 + 5);
VirtualProtectEx(hMain, AddNetworkQueue + 0x14, 1, PAGE_EXECUTE_READWRITE, &tmpProtect);
*(AddNetworkQueue + 0x14) = 0xE9;
*(DWORD *)(AddNetworkQueue + +0x14 + 1) = trmpAddr;
VirtualProtectEx(hMain, AddNetworkQueue + 0x14, 1, PAGE_EXECUTE, &tmpProtect);
while (!unh) Sleep(100);
Не подготовленному человеку захочется умереть на этом моменте. На самом деле все просто. Я лишь заострю внимание на том, что
AddNetworkQueue += *(DWORD *)(AddNetworkQueue + 1) + 5;
всего лишь переходит от обертки с jmp к настоящей функции AddNetworkQueue. Что такое unh
? Это то самое значение ecx
, которое мы пихаем в переменную в нашем обработчике:void __declspec(naked) wrapper_AddNetworkQueue()
{
__asm
{
pushad
pushfd
sub [unh], 0
jnz L1
mov [unh], ecx
L1:
lea eax, [esp + 44] //32 (pushad) + 4 (pushfd) + 4 (push 4) + 4 (ret addr)
push eax
call [handler_AddNetworkQueue]
popfd
popad
add esi, 0x3c //see disasm
push 0x1
jmp [retAddr_AddNetworkQueue]
}
}
void __stdcall handler_AddNetworkQueue(DWORD *stack)
{
NetworkPacket_t *pck = (NetworkPacket_t *)*stack;
if (ShowServerPck)
{
printf("s -> c | %02hhX ", pck->id);
for (int i = 0; i < pck->size; i++)
printf("%02hhX ", pck->data[i]);
printf("\n");
}
}
Здесь naked функция
wrapper_AddNetworkQueue
сохраняет значения всех регистров, получает значение unh
и вызывает наш обработчик. В нем мы комфортно обрабатываем пакет, не боясь за стек, и возвращаем управление назад к врапперу. Он, в свою очередь, восстанавливает затертые инструкции и прыгает к месту, откуда мы прервали оригинальный код. Нус, одной проблемой меньше.Перехват пакетов клиент > сервер
Честно говоря, это самые вкусные пакеты. Именно на них основано 70% всех дюпов. За отправку этих пакетов отвечает не экспортируемая функция, которую принято именовать
SendPacket
:UNetworkHandler::SendPacket(char* msk, ...)
Функция имеет переменной количество параметров, которые достаются из стека исходя из первого аргумента (маски). Как получить адрес этого чюда, ни экспортируемое жи? Все просто, достаточно посмотреть, как она вызывается. Статья не претендует на звание туториала по api lineage 2, поэтому я просто приведу конкретный пример вызова:
Теперь должно быть понятно, зачем нам необходимо было значение регистра
ecx
:SendPacket = (BYTE *)*(DWORD *)(**(DWORD **)(unh + 0x48) + 0x68);
SendPacket += *(DWORD *)(SendPacket + 1) + 5;
SendPacket так же является оберткой и внутри него находится обычный jmp на основную функцию. Ее начало выглядит так:
А в памяти, по аналогии c
AddNetworkQueue
, вот так:Опять банальный прыжок на некий обработчик, но в данном случае игнорировать его мы не можем — он выполняет шифрование пакета. Что делать? Если попробовать перезаписать его на свой прыжок — защита S ругается матом. А если пройти по этому прыжку?
Еее, еще один прыжок. Заспойлерю: там их еще штук 5 будет (чередование jmp/call near). Мы имеем дело с обфускацией, классно. Что делать, если нам лень восстанавливать поток управления?
Метод в лоб
А почему бы на не перезаписать один из этих 5 jmp/near на свой? По началу, я так и сделал, и это была моя фатальная ошибка. Как оказалось, защита S проверяет целостность кода в этих местах и в случае не совпадения с оригиналом — ругается матом. Но! не сразу, Карл! Лишь спустя 15 минут. Конечно, на стадии разработки я не мог себе позволить тестировать работоспособность в течение такого времени. По окончанию работы над всем проектом я был приятно удивлен. Но я не поник, а… совершил вторую фатальную ошибку. Попробовав технику инлайн патча поверх обфусцированного кода aka самостирающиеся хуки (к сожалению, исходников того варианта не осталось, к вопросу о гите). Как это работает: мы ищем любую мусорную инструкцию и затираем ее на jmp near к нашему обработчику. В нем быстренько восстанавливаем оригинальный байты (именно записываем их в память, куда поставили jmp, а не просто выполняем затертые байты в обработчике), делаем свои делишки, возвращаем управление к оригинальной функции. Но такой вариант будет работать всего один раз? Именно, до тех пор, пока мы не установим хук снова. Вспоминаем, что защита S проверяет лишь первые байты функции и если мы поставим хук в конце, то она и слова не скажет. Ставим второй хук в конец
SendPacket
, самый обычный хук, в котором спавним запись jmp near по адресу мусорной инструкции обфусцированного кода. Понять это, с моих слов, не очень просто, но схема такая:- Устанавливаем хук по месту мусорных инструкций обработчика защиты S. В нем, в конце, восстанавливаем эти мусорные инструкции в памяти и прыгаем на них. Таким образом мы как бы стираем свой хук.
- Обработчик защиты S отрабатывает и передает управление к оригинальной функции
SendPacket
. - В конце нее мы ставим второй хук, который заново установит первый.
Почему я нарек эту схемку фатальной ошибкой #2? Дело в том, что такой подход сработает лишь в том случае, если защита проверяет целостность кода из текущего потока. Те если у нас где-нибудь весит второй поток, который проверяет байты первого, то такой трюк не сработает. Так и случилось, я лишь потратил время. Что делать? Мы не можем менять байты в памяти! Как жить?!
Метод сзади
На самом деле, в такой ситуации есть пару вариантов установки хука. Я выбрал один из них: хук методом изменения прав страницы памяти. Да, это не лучший вариант, но сроки горели (напоминаю, это делалось в самом конце, прям перед написанием этой статьи). Тут стоит сделать отсылку к замечательному циклу статей от Broken Sword «Процессор Intel в защищенном режиме». Почитайте, не поленитесь. А так же отсылку к циклу статей Matt Pietrek`а «Win32 SEH изнутри». Гуглится довольно просто. Теперь, я надеюсь, вы поняли, в чем вся соль. Мы изменим атрибут страницы, где находится наша процедура
SendPacket
(на самом деле, я решил менять атрибут страницы памяти, где находится обработчик защиты S, об этом позже). Звучит сложно, но на деле нам необходимо будет выполнить следующий код:VirtualProtectEx(hMain, SendPacket, 1, PAGE_EXECUTE | PAGE_GUARD, &tmpProtect);
Теперь, после того, как клиент вызовет функцию
SendPacket
, будет генерироваться исключение, которое нам предстоит обработать. Очень не хочется писать про tib, поэтому сделаем все совсем простоAddVectoredExceptionHandler(1, wrapper_SendPacket);
Ок, теперь при вызове
SendPacket
мы попадем в wrapper_SendPacket
:long __stdcall wrapper_SendPacket(PEXCEPTION_POINTERS exInfo)
{
if (exInfo->ExceptionRecord->ExceptionCode == STATUS_GUARD_PAGE_VIOLATION)
{
VirtualProtectEx(hMain, SendPacket, 1, PAGE_EXECUTE, &tmpProtect);
if (exInfo->ContextRecord->Eip == (DWORD)SendPacket)
{
handler_SendPacket((DWORD *)exInfo->ContextRecord->Esp + 3); //4 (ret addr) + 4 (ret addr) + 4 (1 arg)
}
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
Как вы могли заметить, в функции
wrapper_SendPacket
вызывается VirtualProtectEx
, который нормализует атрибуты страницы и возвращает управление назад. Но нормализовать атрибуты страницы == снять хук. Воспользуемся вторым методом выше, описанным под заголовком «Метод в лоб», и установим его заново, путем перехвата окончания функции SendPacket
(функция имеет два ret, поэтому мы установим два хука):trmpAddr = (DWORD)wrapper_SendPacketEnd - ((DWORD)SendPacket + 0xb5 + 5); //first ret inside SendPacket
VirtualProtectEx(hMain, SendPacket + 0xb5, 1, PAGE_EXECUTE_READWRITE, &tmpProtect);
*(SendPacket + 0xb5) = 0xE9;
*(DWORD *)(SendPacket + 0xb5 + 1) = trmpAddr;
trmpAddr = (DWORD)wrapper_SendPacketEnd - ((DWORD)SendPacket + 0xc5 + 5); //second ret inside SendPacket
*(SendPacket + 0xc5) = 0xE9;
*(DWORD *)(SendPacket + 0xc5 + 1) = trmpAddr;
VirtualProtectEx(hMain, SendPacket + 0xc5, 1, PAGE_EXECUTE, &tmpProtect);
Сам
wrapper_SendPacketEnd
:void __declspec(naked) wrapper_SendPacketEnd()
{
__asm
{
pushad
pushfd
call [handler_SendPacketEnd]
popfd
popad
add esp, 0x2000 //see disasm
ret
}
}
void __stdcall handler_SendPacketEnd()
{
if (ShowClientPck)
VirtualProtectEx(hMain, SendPacket, 1, PAGE_EXECUTE | PAGE_GUARD, &tmpProtect);
}
Тут ничего сложного, просто устанавливаем атрибут
PAGE_GUARD
и возвращаемся, но не к концу SendPacket, а к вызывающей его функции.Давайте вернемся к
wrapper_SendPacket
. Не забыли? Обратите внимание на проверку if (exInfo->ContextRecord->Eip == (DWORD)SendPacket))
{
...
}
Может быть иначе? К счастью, но в нашем случае к сожалению, да. Когда мы выполняем
VirtualProtectEx
— мы меняем атрибут минимум целой страницы памяти. Те минимум 4 килобайта кода оказываются не доступными. А там могут быть, и они там есть, другие процедуры. Те исключение генерируется не обязательно при вызове SendPacket. Это и есть основной недостаток данного метода (обработчик снимает хук при вызове любых процедур, в конце которых хук не восстанавливается), но он решаем. Есть несколько вариантов исправить его. Мы воспользуемся самым быстрым и не самым качественным. Будем тупо спавинть VirtualProtectEx
c аргументом PAGE_GUARD
. Для этой цели (спойлер: не только для нее) была выбрана экспортируемая функция FPlayerSceneNode::Render(FRenderInterface *)
, которая вызывается основным потоком в циклеЗащита S не ругается, если перехватить ее в самом начале. Перехватываем и спавним
VirtualProtectEx
. Это дает 100% гарантию срабатывания нашего хука? Конечно нет. Лишь 95%. Мне этого хватило. Я не стал заморачиваться и накатывать костыли. Выше я писал, что хук устанавливаю не в адресное пространство engine.dll
, а по адресу обработчика защиты S. Почему? Просто там процент срабатыванияif (exInfo->ContextRecord->Eip == (DWORD)SendPacket))
{
...
}
гораздо больше (проверено эмпирическим методом). Если добавить в хук, который мы установили в конце
SendPacket
, вывод некой строки-индикатора, которая будет выводиться 100% после завершения отправки пакета, то мы увидим следующую картину:Идущие подряд строки
#pck
говорят нам о том, что хук не сработал (те самые 5%). Обобщим изложенную выше кашу:- Меняем атрибуты страницы памяти и устанавливаем обработчик исключений
- Внутри него восстанавливаем оригинальные атрибуты и, если исключение произошло по адресу нашего
SendPacket
, то можно вызывать наш собственный обработчик - В конечном итоге, управление возвращается к оригинальной функции
SendPacket
, в конце которой стоит наш второй хук - Он, в свою очередь, заново устанавливает атрибуты страницы памяти и передает управление на код, который вызвал
SendPacket
- А в это время, в процедуре
Render
, спавнится установка тех же атрибутов на тот же участок памяти
Отправка пакетов на сервер
Самое вкусное, но, после всех танцев с бубном вокруг перехвата пакетов клиент ->сервер, довольно простое. Адрес SendPacket мы научились получать выше, там же подсмотрели пример передачи аргументов данной функции. Что делать? Пробовать вызывать! И довольно много. Пытаемся подсунуть аргументы не из адресного пространства
engine.dll
— получаем в лоб. Пытаемся подсунуть адрес возврата не из адресного пространства engine.dll
— получаем по уху. Пытаемся вызывать функцию не из основного потока, а на прямую из нашей длл`ки — получаем по печени. В конечном итоге рецепт такой:- Защите S плевать на то, откуда вызывается одна из экспортируемых функций
engine.dll
, которая вызываетSendPacket
(а зря!) - Защите S не плевать на то, откуда вызывается
SendPacket
(адрес возврата должен лежать внутриengine.dll
, вызов должен происходить из основного потока - Защите S не плевать на то, в каком адресном пространстве находятся аргументы функции
SendPacket
А вот и лекарство:
- Подделать адрес возврата при вызове функции
SendPacket
- Подделать адресное пространство аргументов, передаваемых в нее
- Совершать вызов из основного потока
Как это сделать? Очень просто! Достаточно найти свободное место внутри
engine.dll
(от выравнивания вполне сгодится) и разместить там один трамплинчик + небольшой буфер. Перейдем от слов к делу:BYTE *Remove = (BYTE *)GetProcAddress(hEngine, "?Remove@?$TArray@E@@QAEXHH@Z");
Remove += *(DWORD *)(Remove + 1) + 5;
pckMsk = (char *)Remove + 0x74; //max 44 chars with zero (43 without). You can find more.
VirtualProtectEx(hMain, pckMsk, 1, PAGE_EXECUTE_READWRITE, &tmpProtect); //
Было найдено первое попавшееся место длиной в 44 байта (можно поискать и побольше). Там разместился буфер, в который будет записана строка, передаваемая в
SendPacket
первым (по факту вторым) аргументом.Что делать с адресом возврата? Достаточно просто подсунуть трамплин внутри
engine.dll
на наш обработчик (после вызова SendPacket
управление перейдет на наш трамплин, а от туда на наш обработчик). Как это выглядит? Вот так:BYTE* RequestRestart = (BYTE *)GetProcAddress(hEngine, "?RequestRestart@UNetworkHandler@@UAEXAAVL2ParamStack@@@Z");
RequestRestart += *(DWORD *)(RequestRestart + 1) + 5;
retAddr_handler_Render = RequestRestart + 0x2b;
trmpAddr = (DWORD)fixupStack_Render - ((DWORD)retAddr_handler_Render + 5);
VirtualProtectEx(hMain, retAddr_handler_Render, 1, PAGE_READWRITE, &tmpProtect);
*retAddr_handler_Render = 0xE9;
*(DWORD *)(retAddr_handler_Render + 1) = trmpAddr;
VirtualProtectEx(hMain, retAddr_handler_Render, 1, PAGE_EXECUTE, &tmpProtect);
Сам
fixupStack_Render
:void __declspec(naked) fixupStack_Render()
{
__asm
{
add esp, [fixupSize] //SendPacket has cdecl convention
mov esp, ebp //prolog of
pop ebp ///////handler_Render
ret //ret to the end of wrapper_Render
}
}
Что за fixupSize? При вызове
SendPacket
fixupSize = 12; //4 (push eax) + 4 (push [pckMsk]) + 4 (push 0x46)
__asm
{
mov ecx, [unh]
mov eax, [ecx + 0x48]
mov ecx, [eax]
mov edx, [ecx + 0x68] //SendPacket
push 0x46
push [pckMsk]
push eax
push [retAddr_handler_Render] //trampoline to fixupStack_Render
jmp edx
}
мы передаем ему переменное количество параметров, следовательно очистка стека лежит на нас. Код в процедуре
fixupStack_Render
этим и занимается. Конечно, сам SendPacket
необходимо вызывать из основного потока, вышеупомянутая экспортируемая функция Render
для такой цели сгодится.Отправка пакетов на клиент
Реализуется аналогично.
Подмена входящих и исходящих пакетов
Достаточно изменить аргументы функций, которые мы
Совсем забыл
- Сервера, на которых все тестировалось — пиртаские
- Приложение писалось под хронику Interlude
- Защита S ругается, если подменить таблицу экспорта
Эпилог
Выражаю свою благодарность каждому участнику форума T, который хоть раз помог мне; с которыми мы искали баги и дюпали сервера. А так же разработчику защиты S: спасибо, что дал мне повод написать этот врайтап. Ну и конечно, пользователям хабра, которые дочитали статью до конца.
Полный исходник прилагается: клац
Видео демонстрация работы пакетника:
На этом прощаюсь.
Комментарии (12)
s77lanselot77s
31.08.2017 13:10+1Супер! Жду еще статей по реверсу
unc1e Автор
31.08.2017 13:13Я, к сожалению, пока не совсем опытный реверсер. Именно поэтому вместо полной эмуляции защиты реализовал ее обход.
boris768
31.08.2017 17:12Как легко жить, когда можно играться с .text секцией.
Хорошая статья, благодарю
Hint
31.08.2017 17:24Хлапа — это не l2ph. И суть в том, что все эти методы хорошо известны и массово применяются. Давным давно (лет 10 назад) было интересно писать защиты, выкладывать бесплатные обходы защит и обновлять их раз в неделю, делиться опытом на форумах и пр. А потом все повзрослели и начали ценить своё время. Разработка под L2 была очень увлекательной (и мы были молодыми), но время прошло, сейчас это просто бизнес.
KorDen32
01.09.2017 14:30Во времена увлечения MMO (где-то на HDD еще пылятся сборки l2j и клиентов Interlude и Gracia, с которыми экспериментировал), с учетом параллельного увлечения шутерами типа Counter-Strike и поверхностного понимания программирования, я откровенно не мог понять, как вообще возможны дюпы, почему это всё не проверяется со стороны сервера? Мне был понятен смысл читов на автоматическое выполнение игровых действий, но не природа дюпов.
Тогда я особо не искал, а потом появились другие интересы, и так для себя ответа на этот вопрос не нашел.
g0rd1as
Школьник? Вообще молодец! Не забрасывай это дело!
unc1e Автор
Спасибо)