Секреты Lineage II: скрытые возможности клиента
Ровно 20 лет назад, 8 июня 2004 года, в корейской компании NCSoft был скомпилирован файл L2Server.exe — основной компонент игрового сервера новейшей на тот момент ММО игры Lineage II The chaotic chronicle: Chronicle 1 — Harbingers of War. В результате произошедших затем событий, всех подробностей которых мы вероятно никогда не узнаем, этот файл, вместе с сопутствующими компонентами, данными и скриптами стал, так скажем, «достоянием общественности», дав начало эре неофициальных серверов, а также огромной популярности Lineage 2 в СНГ, и не только. В этой, и последующих, статьях мы познакомимся с техническими подробностями и секретами как клиента, так и сервера этой игры, некоторые из которых не были широко известны не только игрокам, но и администраторам серверов.
Дисклеймер: Вся информация предоставляется исключительно в ознакомительных целях и получена из открытых источников. Автор не является, и никогда не являлся, сотрудником компании NCSoft или её партнёров, не заключал с ними никаких соглашений, и не имеет никакого отношения и никакой информации касательно произошедших сливов.
В качестве подопытного будем использовать клиент и сервер C1, как в те времена, а также инструментарий тех времён: отладчик OllyDbg с плагином ODbgScript и дизассемблер IDA. Несмотря на то, что некоторую информацию и инструменты сейчас можно просто нагуглить, пройдем весь путь с нуля, как если бы мы оказались в 2004 году, так что никаких питонов, гидр, и прочего новодела.
Начнём с изучения клиента. В корне клиента лежит файл LineageII.exe -, но это лаунчер, нам он не нужен. Можно удалить, чтоб не мешался. Настоящий exe клиента, вместе с dll и конфигами, находится в System и называется l2.bin. Переименуем его в l2.exe и убедимся что клиент нормально запускается.
Экран логина клиента
Настраиваем клиент
Итак, свежеустановленный оригинальный клиент стартует в полноэкранном режиме, а при попытке логина будет пытаться залогиниться на официальный сервер. Нужно разобраться с этими недоразумениями — сделать так, чтоб клиент запускался в окне и логинился на локальный сервер. Клиент основан на модифицированном движке UnrealEngine. В System находится много ini файлов, однако они зашифрованы или запакованы:
l2.ini — виден заголовок, а дальше — ничего не понятно
Разберёмся с этим. Загрузим клиент под дебаггером, поставим брейкпоинт на CreateFileA и CreateFileW, запустим, проскипаем до места, где будет открываться l2.ini. Выйдя из функции kernel32 попадаем сюда:
После срабатывания брейкпоинта на CreateFileW и выхода из функции
Теперь поставим брейкпоинт на ReadFile. После брейка видим такой стек:
Стек после срабатывания брейкпоинта на ReadFile
Поставим брейкпоинт на доступ к памяти по адресу Buffer + 0×20 (пропускаем заголовок файла), запускаем, срабатывает брейк в core.dll. Выходим из функции, попадаем сюда:
После срабатывания брейкпоинта на доступ к памяти и выхода из функции шифрования
Функция шифрования оказалась в экспорте dll-ки, так что даже не нужно гадать чем зашифровано. Добрые корейцы делают всё для людей :) Выйдя еще на уровень выше, попадаем сюда:
Функция шифрования с захардкоженным ключем имеет криптостойкость равную нулю
А вот и ключ шифрования. Теперь можно написать дешифровщик и шифровщик, реализацию алгоритма BlowFish возмём из OpenSSL.
#define La2IniHeaderSize 0x1C
const unsigned char l2_ini_enc_key[] = "[;'.]94-&@%!^+]-31==";
void La2IniDecryptVer212(uint8_t * buf, size_t buf_size) {
BF_KEY key;
BF_set_key(&key, sizeof(l2_ini_enc_key), l2_ini_enc_key);
for (size_t offs = La2IniHeaderSize; offs + BF_BLOCK < buf_size; offs += BF_BLOCK) {
BF_decrypt((unsigned int *)(buf + offs), &key);
}
}
Впрочем, все давно сделано за нас, так что можно воспользоваться тулзой dstuff l2encdec. В расшифрованном l2.ini пропишем:
ServerAddr=127.0.0.1
StartupFullscreen=False
Затем зашифруем обратно и положим файл в System. Теперь клиент стартует в безрамочном окне и логинится на 127.0.0.1:2106. Можно запустить локальный сервер и начать развлекаться. Запуск сервера — это отдельная большая тема, которую я не буду рассматривать в этой статье, отмечу лишь что большинство экспериментов проводилось на самописном минималистичном сервере, на который можно только зайти и соло побегать, так что не удивляйтесь отсутствию мобов и НПЦ.
Находим аргументы командной строки
Теперь посмотрим, принимает ли клиент какие-то аргументы командной строки. Ставим брейкпоинт на GetCommandLineA и GetCommandLineW, запускаем. Срабатывает брейк на GetCommandLineW. Трейсим до выхода из функции, ставим брейк на доступ к памяти по адресу EAX. Брейк срабатывает в функции:
Core.?ParseParam@@YAHPBG0@Z ; ParseParam(ushort const *,ushort const *)
Поскольку она вызывается много раз, воспользуемся скриптом для ODbgScript чтобы сдампить все аргументы:
cycle:
log [esp+4]
log [esp+8]
run
jmp cycle
В результате получаем вот такой список:
Hidden text
STRICT
CONFLICTS
NOGC
NOMMX
NOSSE
SERVER
LAZY
LOG
server
BENCHMARK
320×240
512×384
640×480
800×600
1024×768
1280×960
1280×1024
1600×1200
FirstRun
safe
defaultres
nodeviceid
lanplay
nomusic
NOSOUND
nomusic
windowed
RECORDMOVIE
MEMSTAT
А декомпилировав в IDA функцию ParseParam выясняем, что аргументы должны быть с префиксом '-' или '/':
int __cdecl ParseParam(const unsigned __int16 *a1, wchar_t *Str)
{
...
if ( !v2 )
{
while ( 1 )
{
v3 = appStrfind(v3 + 1, Str);
if ( !v3 )
break;
if ( v3 > a1 )
{
v4 = *(v3 - 1);
if ( v4 == '-' || v4 == '/' )
return 1;
}
}
}
return 0;
}
Настало время воспользоваться полученными знаниями. В первую очередь интересен аргумент LOG, который запускает клиент с открытой игровой консолью:
Клиент запущен с открытой консолью
Из прочих команд:
windowed — по идее должна включать оконный режим, но не работает, вероятнее StartupFullscreen из l2.ini приоритетнее;
RECORDMOVIE — таки записывает видео, но в виде кучи скриншотов;
MEMSTAT — пишет в лог статистику при закрытии клиента.
Добываем список консольных команд
Исследуем что же можно сделать через игровую консоль. Подбором обнаруживаем что работают команды exit и quit. Эти строки встречаются в файлах Engine.dll и Core.dll. Грузим Engine.dll и находим где используются эти строки:
.text:104D14A2 lea edx, [ebp+arg_0]
.text:104D14A5 push offset aExit ; "EXIT"
.text:104D14AA push edx
.text:104D14AB call edi ; ParseCommand(ushort const * *,ushort const *) ; ParseCommand(ushort const * *,ushort const *)
.text:104D14AD add esp, 8
.text:104D14B0 cmp eax, ebx
.text:104D14B2 jnz loc_104D17C6
.text:104D14B8 lea eax, [ebp+arg_0]
.text:104D14BB push offset aQuit ; "QUIT"
.text:104D14C0 push eax
.text:104D14C1 call edi ; ParseCommand(ushort const * *,ushort const *) ; ParseCommand(ushort const * *,ushort const *)
ParseCommand — это функция ? ParseCommand@@YAHPAPBGPBG@Z из Core.dll.
Теперь запустим клиент под дебаггером и поставим брейкпоинт на ParseCommand. Введя произвольную команду, обнаруживаем что команды действительно парсятся этой функцией. Теперь можно сдампить список похожим скриптом:
cycle:
log [[esp+4]]
log [esp+8]
run
jmp cycle
В итоге получаем такой список:
Hidden text
OPEN
START
SERVERTRAVEL
DISCONNECT
RECONNECT
EXIT
QUIT
GETCURRENTTICKRATE
GETMAXTICKRATE
GSPYLITE
SAVEGAME
CANCEL
SOUND_REBOOT
SHOWEXTENTLINECHECK
SHOWLINECHECK
SHOWPOINTCHECK
KDRAW
KSTEP
KSTOP
KSAFETIME
MEMSTAT
CONFIGHASH
EXIT
QUIT
RELAUNCH
DEBUG
DIR
MEM
DUMPNATIVES
GET
SET
OBJ
GTIME
DUMPCACHE
SHOWLOG
TakeFocus
EditDefault
EditActor
CopyToClipboard
HideLog
Preferences
BRIGHTNESS
CONTRAST
GAMMA
PAUSESOUNDS
UNPAUSESOUNDS
STOPSOUNDS
WEAPONRADIUS
ROLLOFF
GRAPH
L2Debug
L2DebugWindow
FLUSH
STAT
CRACKURL
PACKETCOUNTSTART
PACKETCOUNTSTOP
Введя команду PACKETCOUNTSTART, получаем такую статистику:
Человеческие названия пакетов — как удобно, спасибо корейцам.
Нужно больше команд!
Известно, что существуют команды, отдаваемые через чат клиента, например /loc — показывает текущие координаты персонажа. Поэкспериментировав с разными префиксами обнаруживаем, команды с префиксом /// исполняются аналогично введенным в консоли. Кроме того, список xrefs на ParseCommand в IDA намекает, что команд может быть больше, чем мы обнаружили. Еще есть префикс //, используемый для административных команд, и оба эти префикса работают только если у персонажа статус GM-а.
Попробуем аналогично сдампить список команд, введенных с префиксом. Правда оказывается, что ParseCommand также используется движком для обработки команд PlayerPawnMoveTo, CameraYaw и прочих, так что придется помучиться с этим спамом прежде чем получить искомый результат. В итоге получаем вот такой список:
Hidden text
BUTTON
PULSE
TOGGLE
AXIS
JOYPAD
COUNT
KEYNAME
LOCALIZEDKEYNAME
KEYBINDING
L2Restart
Warp
rwarp
MoveWarp
L2WaterInfo
L2WaterReflect
EnterChat
L2EVENTON
L2EVENTOFF
GETITEM
TARGETCHANGE
SHOWCOMPASS
HIDECOMPASS
SCENE0
SCENE1
ANTIPORTAL
TELEPORT
WAITMODECHANGE
MOVEMODECHANGE
CONTROLLERVIEW
PAWNVIEW
SPAWNPLAYERPAWN
DeletePlayer
SPAWNACTOR
SPAWNNPCS
SPAWNPCS
AUTOSPAWNPC
PlayerMove
DumpActor
SPAWNVEHICLES
SPAWNITEM
SPAWNEDPAWNMOVETO
STOPPAWNMOVING
DEFAULTCAMERA
FIXEDDEFAULTCAMERA
TURNBACK
MESHCHANGE
TEXTURECHANGE
DISTANCEFOG
DISTANCEFOGRANGE
PERSPECTIVE
GROUNDSPEEDUP
GROUNDSPEEDDOWN
CAMERAVIEWHEIGHTADJUST
ZOOMINHOLD
ZOOMOUTHOLD
ZOOMINPRESS
ZOOMOUTPRESS
SELECTINGCANCEL
TextCapture
Crash
PLAYERPAWNMOVETO
KEYBOARDBACKMOVESTART
KEYBOARDBACKMOVEFINISH
KEYBOARDMOVESTART
KEYBOARDMOVEFINISH
JOYSTICKMOVE
LEFTTURNINGSTART
LEFTTURNINGFINISH
RIGHTTURNINGSTART
RIGHTTURNINGFINISH
STEPMOVE
COMBOANIMPLAY
CHANGEANIM
CheckGrp
Addabnormal
deleteabnormal
Lodchange
fh
shake
Env Reload
SETTIME
SETTIMERATIO
CancelMAGICTEST
DeleteSelectedActor
pv
PawnViewer
nv
NpcViewer
sv
SkillViewer
Cast
SkillRemain
addcubic
decubic
cubicskill
ATTACKSPEEDDOWN
ATTACKSPEEDUP
setwyvern
SVS
BoneSim
ReduceLOD
KeepMinFrame
SkipAnim
Hitwater
SHADOW
DEFAULTSHADOW
RIDE
UNRIDE
ANIMPLAY
CAMERAPITCH
CAMERAYAW
YAWTURN
TRANSFER
BuildZone
LoadPath
Limit
C_TELEPORT
C_RMODE
GEODATA
SEAMLESS
MAPLOC
SHOWBORDERLINE
SHOWSECTORS
SaveMemInfo
CacheTexture
DUMPRESOURCEHASH
FIRSTCOLOREDMIP
NEARCLIP
D3DRESOURCES
SUPPORTEDRESOLUTION
ISFULLSCREEN
GETPING
INJECT
NETSPEED
LANSPEED
SHOWALL
REPORT
SHOT
SHOWACTORS
HIDEACTORS
RMODE
REND
SHOW
CINEMATICS
CINEMATICSRATIO
FIXEDVISIBILITY
TOGGLEREFRAST
EXEC
SHOWEXTENTLINECHECK
SHOWLINECHECK
SHOWPOINTCHECK
KDRAW
KSTEP
KSTOP
KSAFETIME
OPEN
START
SERVERTRAVEL
DISCONNECT
RECONNECT
EXIT
QUIT
GETCURRENTTICKRATE
GETMAXTICKRATE
GSPYLITE
SAVEGAME
CANCEL
SOUND_REBOOT
SHOWEXTENTLINECHECK
SHOWLINECHECK
SHOWPOINTCHECK
KDRAW
KSTEP
KSTOP
KSAFETIME
MEMSTAT
CONFIGHASH
EXIT
QUIT
RELAUNCH
DEBUG
DIR
MEM
DUMPNATIVES
GET
SET
OBJ
GTIME
DUMPCACHE
SHOWLOG
TakeFocus
EditDefault
EditActor
CopyToClipboard
HideLog
Preferences
BRIGHTNESS
CONTRAST
GAMMA
PAUSESOUNDS
UNPAUSESOUNDS
STOPSOUNDS
WEAPONRADIUS
ROLLOFF
GRAPH
L2Debug
L2DebugWindow
FLUSH
STAT
CRACKURL
PACKETCOUNTSTART
PACKETCOUNTSTOP
STOPMOUSE
MOVEMOUSE
ENDFULLSCREEN
TOGGLEFULLSCREEN
GETCURRENTRES
GETCURRENTCOLORDEPTH
GETCOLORDEPTHS
GETCURRENTRENDERDEVICE
SETRES
TEMPSETRES
Оказывается через клиент доступно гораздо больше команд: 222 из клиента, и только 57 — из консоли! Настало время посмотреть, что же они делают.
Проверяем найденные команды
В списке есть 2 команды телепорта — TELEPORT и C_TELEPORT, очеводно у них должны быть аргументы. Находим в IDA как они парсятся:
.text:104E667E mov ecx, [ebp+arg_0]
.text:104E6681 lea eax, [ebp+var_164]
.text:104E6687 push eax
.text:104E6688 push offset asc_10791768 ; "X="
.text:104E668D push ecx
.text:104E668E call ds:?Parse@@YAHPBG0AAM@Z ; Parse(ushort const *,ushort const *,float &)
.text:104E6694 add esp, 0Ch
.text:104E6697 cmp eax, esi
.text:104E6699 jz loc_104E5DC0
.text:104E669F mov eax, [ebp+arg_0]
.text:104E66A2 lea edx, [ebp+var_160]
.text:104E66A8 push edx
.text:104E66A9 push offset aY ; "Y="
.text:104E66AE push eax
.text:104E66AF call ds:?Parse@@YAHPBG0AAM@Z ; Parse(ushort const *,ushort const *,float &)
.text:104E66B5 add esp, 0Ch
.text:104E66B8 cmp eax, esi
.text:104E66BA jz loc_104E5DC0
.text:104E66C0 mov edx, [ebp+arg_0]
.text:104E66C3 lea ecx, [ebp+var_15C]
.text:104E66C9 push ecx
.text:104E66CA push offset aZ ; "Z="
.text:104E66CF push edx
.text:104E66D0 call ds:?Parse@@YAHPBG0AAM@Z ; Parse(ushort const *,ushort const *,float &)
В принципе это было очевидно — аргументы для телепорта: X=
SHOWLOG, HideLog — открывает и закрывает окно консоли. Альтернатива аргументу /LOG.
Preferences — открывает вот такие настройки:
Preferences открывает гораздо более богатые настройки, чем те, которые доступны в клиенте.
SPAWNNPCS Num=
Результат команды SPAWNNPCS
SPAWNPCS Num=
Результат команды SPAWNPCS
RIDE TYPE=
А вы знали, что страйдеры были уже в C1?
И неправильные виверны
Правда работает кривовато — после этого клиент практически зависает. Забегая вперед скажу, что маунты есть и в сетевом протоколе, и оно работает!
RMODE, C_RMODE
RMODE 1 — можно видеть сквозь стены
или такой:
RMODE 7 — текстуры не завезли
SHOWBORDERLINE, SHOWSECTORS — включает это:
SHOWBORDERLINE, SHOWSECTORS — вероятно, оно зачем-то нужно
SEAMLESS ON/OFF — включает/выключает подгрузку соседних фрагментов карты. Если выключить — соседние фрагменты не будут подгружаться, можно дойти до края земли и упасть:
Земля не только плоская, но и квадратная
MAPLOC — выводит в консоль X Y текущего фрагмента карты, например MapX=21, MapY=19 — эльфятник.
GEODATA — пытается грузить файл по пути формата: .\GeoData%d_%d_conv.dat, где числа вместо %d соответствуют координатам MAPLOC. Очевидно, это серверная геодата. Дадим клиенту то, что он хочет — скопируем геодату в System\GeoData. Теперь мы можем взглянуть на мир LA2 глазами сервера:
Серверная геодата
Видно, что «разрешение» тут гораздо ниже, чем в клиенте — мир состоит из клеток примерно с ширину персонажа, а если точнее — 16×16 координатных единиц (которые отображаются по /loc). Зеленые стрелочки показывают проходимые направления, а красные — непроходимые. А если участок 8×8 клеток не содержит больших перепадов высот и непроходимых направлений — он объединяется в одну большую клетку:
Разные типы геодаты
Заключение
Мы познакомились лишь с частью скрытого в клиенте LA2 функционала, часть из которого очевидно была предназначена для тестирования и разработки игры, однако могла пригодиться и игрокам. Однако в клиенте есть ещё кое-что очень интересное. В следующей статье мы выясним, что же делает команда BuildZone.