- PVSM.RU - https://www.pvsm.ru -

Обходим коммерческую защиту методом black box и пишем packet hack для lineage 2

Пролог

Все началось год назад, когда один из моих товарищей с форума T предложил переписать известную всему читерскому миру программу l2phx за авторством многоуважаемого xkor`а.
Сам l2phx (l2 packet hack, пакетник, хлапа) представляет из себя сниффер входящих и исходящих пакетов (все реализовано через LSP) клиента lineage 2 (существуют версии для других mmorpg), с возможностью отправки/подмены отдельных пакетов. Xkor постарался как следуют: реализовал методы обхода шифрации, красивый gui и тп. Но злобным админам фришек такое приложение не понравилось: оно существенно убивало их доход на старте очередных однодневок. Да-да, были времена когда любой нонейм мог зайти на любой сервер и устроить полную вакханалию этим инструментом. Тогда же и появились всяческие коммерческие защиты, которые безуспешно блокировали использование пакетника, а самые хитрые из них еще дополнительно шифровали трафик. Одна из таких защит живет на последнем издыхании и по сей день: встречайте, защита S. Сегодня защита S стоит на всех топовых серверах lineage 2. К слову, xkor предусмотрел такой исход и реализовал возможность самостоятельно написать модуль расшифровки пакетов (newxor.dll). Да только писать его было не рационально: новый сервер == новый newxor. Читерство по l2 постепенно начало умирать, ибо новички были не в состоянии отправлять пакеты методами изменения памяти клиента (HxD, cheat engine и тд).

Тогда я отнесся к этой затеи не очень серьезно: написал модуль перехвата пакетов клиент -> сервер и забросил. Почему? Потому. Но буквально 3 дня назад я решил возобновить работу над этим проектом и опубликовать данную статью. Почему? Комьюнити читеров l2 на данный момент мертво. Все баги и отмывы к ним находятся в руках 10 человек, которые общаются между собой в скайпе и на форуме T. И я тоже решил уйти. А если уходить, то лишь красиво)) Два года назад я мечтал о работающем пакетнике, а сегодня он мне не нужен.

Дисклеймер

Сентябрь горит в виду своего возраста совсем скоро, а именно 1 сентября, я буду встречать свой последний год в одой из московских школ. Список школьной литературы даже не открывался, а книги для подготовки к ЕГЭ пылятся в шкафу. Времени совсем нет. Часть кода накинута на скорую руку, тк не совсем приятные известия выяснились лишь после завершения работы, но об этом позже. Статья тоже написана не совсем литературным языком. Но что есть, то есть.

Перехват пакетов сервер -> клиент

Все пакеты, которые клиент получает от сервера, в конечном итоге можно отловить по вызову экспортируемой UNetworkHandler::AddNetworkQueue внутри engine.dll:

image

Она представляет из себя обертку, внутри которой идет джамп на оригинальную функцию:

image

Совершенно очевидно, что где-то здесь хитрая защита S и расшифровывает пакеты, которые шифровались ей дополнительно на сервере. Если посмотреть на то, как этот же код выглядит в памяти, то мы увидим следующее:

image

Как банально, это самый обычный 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, поэтому я просто приведу конкретный пример вызова:

image

Теперь должно быть понятно, зачем нам необходимо было значение регистра

ecx:

SendPacket = (BYTE *)*(DWORD *)(**(DWORD **)(unh + 0x48) + 0x68);
SendPacket += *(DWORD *)(SendPacket + 1) + 5;

SendPacket так же является оберткой и внутри него находится обычный jmp на основную функцию. Ее начало выглядит так:

image

А в памяти, по аналогии c AddNetworkQueue, вот так:

image

Опять банальный прыжок на некий обработчик, но в данном случае игнорировать его мы не можем — он выполняет шифрование пакета. Что делать? Если попробовать перезаписать его на свой прыжок — защита S ругается матом. А если пройти по этому прыжку?
image
Еее, еще один прыжок. Заспойлерю: там их еще штук 5 будет (чередование jmp/call near). Мы имеем дело с обфускацией, классно. Что делать, если нам лень восстанавливать поток управления?

Метод в лоб

А почему бы на не перезаписать один из этих 5 jmp/near на свой? По началу, я так и сделал, и это была моя фатальная ошибка. Как оказалось, защита S проверяет целостность кода в этих местах и в случае не совпадения с оригиналом — ругается матом. Но! не сразу, Карл! Лишь спустя 15 минут. Конечно, на стадии разработки я не мог себе позволить тестировать работоспособность в течение такого времени. По окончанию работы над всем проектом я был приятно удивлен. Но я не поник, а… совершил вторую фатальную ошибку. Попробовав технику инлайн патча поверх обфусцированного кода aka самостирающиеся хуки (к сожалению, исходников того варианта не осталось, к вопросу о гите). Как это работает: мы ищем любую мусорную инструкцию и затираем ее на jmp near к нашему обработчику. В нем быстренько восстанавливаем оригинальный байты (именно записываем их в память, куда поставили jmp, а не просто выполняем затертые байты в обработчике), делаем свои делишки, возвращаем управление к оригинальной функции. Но такой вариант будет работать всего один раз? Именно, до тех пор, пока мы не установим хук снова. Вспоминаем, что защита S проверяет лишь первые байты функции и если мы поставим хук в конце, то она и слова не скажет. Ставим второй хук в конец SendPacket, самый обычный хук, в котором спавним запись jmp near по адресу мусорной инструкции обфусцированного кода. Понять это, с моих слов, не очень просто, но схема такая:

  1. Устанавливаем хук по месту мусорных инструкций обработчика защиты S. В нем, в конце, восстанавливаем эти мусорные инструкции в памяти и прыгаем на них. Таким образом мы как бы стираем свой хук.
  2. Обработчик защиты S отрабатывает и передает управление к оригинальной функции SendPacket.
  3. В конце нее мы ставим второй хук, который заново установит первый.

Почему я нарек эту схемку фатальной ошибкой #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 *), которая вызывается основным потоком в цикле

image

Защита S не ругается, если перехватить ее в самом начале. Перехватываем и спавним VirtualProtectEx. Это дает 100% гарантию срабатывания нашего хука? Конечно нет. Лишь 95%. Мне этого хватило. Я не стал заморачиваться и накатывать костыли. Выше я писал, что хук устанавливаю не в адресное пространство engine.dll, а по адресу обработчика защиты S. Почему? Просто там процент срабатывания

if (exInfo->ContextRecord->Eip == (DWORD)SendPacket))
{
...
}

гораздо больше (проверено эмпирическим методом). Если добавить в хук, который мы установили в конце SendPacket, вывод некой строки-индикатора, которая будет выводиться 100% после завершения отправки пакета, то мы увидим следующую картину:
image
Идущие подряд строки #pck говорят нам о том, что хук не сработал (те самые 5%).
Обобщим изложенную выше кашу:

  1. Меняем атрибуты страницы памяти и устанавливаем обработчик исключений
  2. Внутри него восстанавливаем оригинальные атрибуты и, если исключение произошло по адресу нашего SendPacket, то можно вызывать наш собственный обработчик
  3. В конечном итоге, управление возвращается к оригинальной функции SendPacket, в конце которой стоит наш второй хук
  4. Он, в свою очередь, заново устанавливает атрибуты страницы памяти и передает управление на код, который вызвал SendPacket
  5. А в это время, в процедуре Render, спавнится установка тех же атрибутов на тот же участок памяти

Отправка пакетов на сервер

Самое вкусное, но, после всех танцев с бубном вокруг перехвата пакетов клиент ->сервер, довольно простое. Адрес SendPacket мы научились получать выше, там же подсмотрели пример передачи аргументов данной функции. Что делать? Пробовать вызывать! И довольно много. Пытаемся подсунуть аргументы не из адресного пространства engine.dll — получаем в лоб. Пытаемся подсунуть адрес возврата не из адресного пространства engine.dll — получаем по уху. Пытаемся вызывать функцию не из основного потока, а на прямую из нашей длл`ки — получаем по печени. В конечном итоге рецепт такой:

  1. Защите S плевать на то, откуда вызывается одна из экспортируемых функций engine.dll, которая вызывает SendPacket (а зря!)
  2. Защите S не плевать на то, откуда вызывается SendPacket (адрес возврата должен лежать внутри engine.dll, вызов должен происходить из основного потока
  3. Защите S не плевать на то, в каком адресном пространстве находятся аргументы функции SendPacket

А вот и лекарство:

  1. Подделать адрес возврата при вызове функции SendPacket
  2. Подделать адресное пространство аргументов, передаваемых в нее
  3. Совершать вызов из основного потока

Как это сделать? Очень просто! Достаточно найти свободное место внутри 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 для такой цели сгодится.

Отправка пакетов на клиент

Реализуется аналогично.

Подмена входящих и исходящих пакетов

Достаточно изменить аргументы функций, которые мы безуспешно научились перехватывать выше.

Совсем забыл

  1. Сервера, на которых все тестировалось — пиртаские
  2. Приложение писалось под хронику Interlude
  3. Защита S ругается, если подменить таблицу экспорта

Эпилог

Выражаю свою благодарность каждому участнику форума T, который хоть раз помог мне; с которыми мы искали баги и дюпали сервера. А так же разработчику защиты S: спасибо, что дал мне повод написать этот врайтап. Ну и конечно, пользователям хабра, которые дочитали статью до конца.

Полный исходник прилагается: клац [1]

Видео демонстрация работы пакетника:

На этом прощаюсь.

Автор: unc1e

Источник [2]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/zashhita-ot-botov/263063

Ссылки в тексте:

[1] клац: https://github.com/unc1e/Lineage-2-Intrelude-packet-hack-for-SmartGuard

[2] Источник: https://habrahabr.ru/post/336842/