몇 달 전, 추억에 잠겨 리니지2를 다시 조금 해 보고 싶어졌습니다. 예전에는 대부분의 서버가 레벨업을 하거나, 돈을 벌거나, 장비 제작에 필요한 재료를 모으기 위해 파밍하는 수많은 봇으로 가득했습니다.
제가 아는 한, 현재 구할 수 있고 제대로 작동하는 봇은 모두 유료이며, 대부분은 서버에서 치트 도구로 탐지됩니다!
그래서 만드는 과정 자체가 재미있을 것 같아 직접 봇을 만들어 보기로 했습니다. 이 글에서는 제가 리니지2용 봇을 만든 방식을 소개합니다.
면책 조항
리니지2는 저물어 가는 MMORPG이지만, 봇 사용은 일부 서버에서는 허용되고 다른 서버에서는 치트 도구로 간주되어 금지됩니다. 서버에 따라서는 이런 소프트웨어를 사용했다가 영구 정지를 당할 수도 있습니다.
소개
봇이라는 개념이 낯선 분들을 위해 본론에 들어가기 전에 간단히 설명하겠습니다. 봇이 무엇인지는 위키백과 문서를 읽어 보면 감을 잡을 수 있습니다.
리니지2는 파밍을 아주 많이 요구하는 MMORPG입니다. 레벨업을 하는 주된 방법도 몬스터 사냥, 돈을 버는 주된 방법도 몬스터 사냥, 더 좋은 장비를 만드는 주된 방법도 몬스터 사냥입니다. 지루한 게임처럼 들릴 수도 있지만, 제 개인적인 생각으로 리니지2는 아주 독특한 PvP(플레이어 대 플레이어) 게임플레이를 갖추고 있어서 다른 많은 MMORPG보다 훨씬 재미있습니다.
파밍이 이렇게 많이 필요하다 보니, 몬스터 사냥을 자동화할 수 있다면 플레이어에게 큰 도움이 됩니다. 밤에는 캐릭터가 알아서 파밍하게 두고 낮에는 직접 플레이하면서 많은 보상을 얻을 수 있으니까요.
제가 설계한 봇은 근처 몬스터를 타겟팅해 공격하고, 스킬을 사용하고, 아이템을 줍고, 필요할 때 휴식을 취할 수 있습니다.
리니지2는 (아마도 오래된) 버전의 WinLicense로 보호되어 있어서 실행 파일의 일부 함수가 가상화되어 있을 수 있지만, 우리에게는 전혀 문제가 되지 않습니다. 또한 게임 서버들은 봇을 막기 위해 GameGuard, SmartGuard, LameGuard 같은 보호 솔루션을 사용하는데, 어째서인지 제 봇은 접속해 본 어떤 서버에서도 전혀 탐지되지 않았습니다.
리니지2는 공개된 프리서버 에뮬레이터도 많고 패킷 해킹에 관한 다양한 연구도 있어서, 온라인에서 참고할 자료가 많았기 때문에 전체 작업이 그리 어렵지 않았습니다.
봇 만들기
설계 선택
봇은 크게 두 종류로 나눌 수 있습니다. 완전한 아웃게임(Out-of-Game, OOG) 봇과 인게임(In-Game, IG) 봇입니다.
OOG 봇을 만들기 어려운 이유는 (네트워크 암호화를 포함한) 클라이언트-서버 프로토콜 전체를 리버스 엔지니어링하고, 이해하고, 구현해야 하기 때문입니다. 하지만 그만큼 보람도 큰데, 게임 클라이언트 없이도 봇을 실행할 수 있기 때문입니다. 즉, 훨씬 가볍고 여러 개의 봇 인스턴스를 실행하기도 훨씬 쉬워집니다.
리니지2의 경우에는 IG 봇을 만드는 편이 훨씬 쉽습니다. 리니지2는 클라이언트가 어떤 행동도 예측하지 않고 서버가 보내는 것에만 반응하도록 구현되어 있기 때문입니다. 즉, 실행 중인 클라이언트를 이용해 패킷 전송을 흉내 내기만 하면 됩니다. 그러면 서버가 그 행동을 승인(또는 거부)한 뒤, 게임 클라이언트가 우리가 보낸 패킷에 맞춰 반응합니다. 다만 IG 봇은 게임 프로세스에 어느 정도 개입해야 하므로 번거로울 수 있고, 안티 치트에 탐지될 가능성도 있습니다.
리니지2는 오래된 게임이라 안티 치트가 있더라도 우회하기가 그리 어렵지 않을 것이라 짐작했기 때문에 IG 봇으로 가기로 했습니다.
특정 패킷 보내기
패킷 전송 함수 찾기
무언가를 자동화하는 것이 목적이었으므로, 가장 먼저 할 일은 원하는 패킷을 직접 보내서 단일 작업을 자동화하는 것이었습니다.
리니지2는 전적으로 TCP로 통신하며, 먼저 여러 게임 서버를 총괄하는 로그인 서버에 접속합니다. 아래 단계들은 이미 게임에 들어가 게임 서버에 연결된 상태에서 패킷을 보내는 과정을 설명합니다.
패킷을 보내려면 먼저 패킷을 전송하는 함수를 찾아야 했습니다. 게임은 윈도우 네이티브 라이브러리인 ws2_32.dll의 send 함수를 사용합니다. 거기에 브레이크포인트를 걸고, 게임 안에서 브레이크포인트가 걸릴 만한 행동을 하기만 하면 됐습니다. send 함수에 전달되는 인자에는 이미 암호화된 패킷이 들어 있으므로, 스택 프레임을 보고 send 함수가 어디서 호출되는지 확인해야 합니다. 거기서부터 약간의 리버스 엔지니어링을 하면 무슨 일이 일어나는지 이해할 수 있습니다.
패킷 전송 함수에 걸린 브레이크포인트
위 스크린샷은 게임 채팅에 메시지를 입력해 보낼 때 패킷 전송 함수가 호출되는 모습입니다. 스택 프레임의 첫 번째 값은 리턴 주소이고, 그 뒤로 함수 인자들이 이어집니다. 첫 번째 인자는 윈도우 TCP 소켓을 담고 있는 어떤 자료 구조를 가리키는 고정 포인터입니다. 두 번째 인자는 널 종료 문자열로, 각 문자가 패킷 필드의 타입을 나타냅니다. 이 경우(채팅 메시지 전송) 패킷에는 다음 인자들이 들어 있습니다.
- "c" (
char) :0x49, "메시지 전송"에 해당하는 패킷 ID - "S" (
String) :L"Hello", 널 종료 와이드 문자열을 가리키는 포인터 - "d" (
dword) :0x00000000, 채널 ID
패킷 재전송하기
"패킷 전송" 함수를 호출하기 위해, 게임 프로세스에 DLL을 인젝션해서 스레드를 하나 만들고 바로 그 함수를 호출하게 하기로 했습니다.
DLL 코드는 꽤 짧습니다.
DWORD SendPacketFP;
DWORD(_stdcall* sendPacket)(DWORD FP, const char* format, ...);
#define DATA_SEND_ADDR 0x3E3B80 // engine.dll 내 "패킷 전송" 함수의 오프셋
#define DATA_SEND_SOCKET_INFO 0xFD890000 // 네트워크 구조체의 오프셋
void ProcessAttach()
{
DWORD EngineDLLBase = (DWORD) GetModuleHandle(L"engine.dll");
sendPacket = (DWORD(_stdcall *)(DWORD, const char*, ...)) EngineDLLBase + DATA_SEND_ADDR;
SendPacketFP = DATA_SEND_SOCKET_INFO; // 네트워크 구조체를 가리키는 포인터
const char format[] = "cSd";
const WCHAR* message = L"Hello";
DWORD parameters[3] = { 0 };
parameters[0] = 0x49; // 패킷 ID
parameters[1] = (DWORD) message; // 보낼 메시지를 가리키는 포인터
parameters[2] = 0x00000000; // 일반 채팅 ID
sendPacket(SendPacketFP, format, parameters[0], parameters[1], parameters[2]);
}
BOOL APIENTRY DllMain( HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)ProcessAttach, 0, 0, NULL);
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
게임 프로세스에 DLL을 인젝션하는 방법은 여러 가지가 있습니다. 저는 아주 단순한 인젝터를 직접 만들기로 했고, 이 인젝터는 다음 작업을 수행합니다.
OpenProcess로 게임 프로세스l2.exe를 엽니다VirtualAllocEx로 게임 프로세스의 주소 공간에 메모리를 할당합니다WriteProcessMemory로 그 공간에.dll의 전체 경로를 씁니다CreateRemoteThread로LoadLibraryW를 호출해 라이브러리를 로드합니다
이제 라이브러리를 로드하면 서버로 패킷이 전송되고, 게임 안에서 바로 결과를 확인할 수 있습니다.
공유 라이브러리에서 패킷 전송 성공
멋지네요. 이제 게임 안에서 행동을 할 수 있게 되었지만, 주변 환경에 대한 정보도 얻어야 합니다.
게임에서 정보 얻기
봇이 동작하려면 플레이어의 현재 위치, 체력, 인벤토리, 스킬 같은 정보뿐 아니라, NPC(Non Playable Character)나 근처의 다른 플레이어, 몬스터 같은 주변 개체에 대한 정보도 게임에서 수집해야 합니다.
이를 위한 방법은 두 가지밖에 없다고 봅니다. 게임의 가상 주소 공간에서 이 모든 구조체의 위치를 찾아 게임 메모리를 들여다보거나, 게임 클라이언트가 하듯이 서버에서 받은 패킷을 그냥 파싱하는 것입니다. 메모리 분석만으로는 필요한 정보를 전부 안정적으로 수집하기 어려울 것 같아서 두 번째 방법을 택했습니다. 하지만 장기적으로 보면 클라이언트-서버 프로토콜을 이해할 필요가 없으니 첫 번째 방법이 더 쉬웠을지도 모릅니다.
앞에서 한 것과 마찬가지로, ws2_32.dll의 recv 함수에 브레이크포인트를 걸고 스택 프레임을 확인해 어떤 함수가 recv를 호출하는지 찾을 수 있습니다. 그다음에는 패킷이 복호화될 때까지 함수를 하나씩 스텝 오버하며 따라가면, 패킷 파싱을 시작할 수 있습니다.
패킷을 받았다는 사실을 봇에 알리려면 "패킷 수신" 함수를 후킹해야 합니다. 그래야 패킷을 덤프한 다음 실행 흐름을 다시 게임에 넘겨줄 수 있습니다.
LPVOID Hook(LPVOID functionToHook, LPVOID myFunction, size_t size)
{
DWORD old;
DWORD old2;
// x86에서 'jmp addr' 명령어는 길이가 5바이트이고 0xE9로 시작한다
const JMP_INSTR_SIZE = 5;
// 메모리를 할당하고 기존 바이트(원래 명령어)를 그곳에 복사한다
oldInstructions = malloc(JMP_INSTR_SIZE + size);
VirtualProtect(oldInstructions, size + 5, PAGE_EXECUTE_READWRITE, &old);
memcpy(oldInstructions, functionToHook, size);
// 후킹한 함수에서 복사해 온 바이트 뒤에 점프를 추가해
// 후킹한 함수의 나머지 부분으로 되돌아가게 한다.
// 이렇게 하면 oldInstructions를 호출했을 때 원래의 functionToHook처럼 동작한다
*(BYTE*)((DWORD)oldInstructions + size) = 0xE9;
*(DWORD*)((DWORD)oldInstructions + size + 1) = (DWORD)((DWORD)functionToHook + size) - (DWORD)((DWORD)oldInstructions + size) - JMP_INSTR_SIZE;
// 후킹할 함수를 패치해 우리 함수로 점프하게 한다
VirtualProtect(functionToHook, JMP_INSTR_SIZE, PAGE_EXECUTE_READWRITE, &old);
*(BYTE*)functionToHook = 0xE9;
*(DWORD*)((DWORD)functionToHook + 1) = (DWORD)myFunction - (DWORD)functionToHook - JMP_INSTR_SIZE;
VirtualProtect(functionToHook, JMP_INSTR_SIZE, old, &old2);
return oldInstructions;
}
패킷과 그 의미를 이해하는 데 필요한 자료는 거의 모든 리니지2 버전에 대해 온라인에 넘쳐나기 때문에, 이 부분에서는 리버스 엔지니어링이 거의 필요 없었습니다.
봇 아키텍처
소프트웨어 설계
이제 게임으로 패킷을 보내고 받을 수 있게 되었으니, 다음과 같은 아키텍처를 구상했습니다.
L2Bot 소프트웨어 아키텍처
그림에서 보듯이, 먼저 DLL을 인젝션하면 DLL이 자동으로 네임드 파이프를 생성합니다. 게임이 패킷을 받으면 이 파이프를 통해 봇에 패킷 수신을 알리고, 반대로 봇이 게임 안에서 상호작용하거나 행동을 해야 할 때도 이 파이프를 통해 보낼 수 있습니다.
이 아키텍처 덕분에 게임 프로세스에 봇을 매끄럽게 주입하고 제거할 수 있으며, 여러 게임 인스턴스도 처리할 수 있습니다.
그래픽 인터페이스는 이미 익숙하다는 것 외에는 특별한 이유 없이 Qt를 선택했습니다.
봇 오토마타
봇을 "똑똑하게" 만들기 위해 외부 이벤트에 반응할 수 있는 일종의 오토마타를 사용하기로 했습니다. 현재 오토마타는 다음과 같이 나타낼 수 있습니다.
봇의 오토마타
실제로는 언제든 많은 일이 일어날 수 있기 때문에 훨씬 더 복잡합니다! 예를 들어 체력이 매우 낮은 상태로 Start 상태에 도달할 수도 있는데, HP(Health Points)가 낮은 상태에서 캐릭터가 몬스터를 때리기 시작하는 건 원치 않으므로 Rest 상태로 넘어가는 편이 낫습니다. 또 어떤 것도 즉시 일어나지 않는다는 점을 염두에 두어야 하므로, 여러 검사를 아주 자주 해야 합니다. 공격할 몬스터를 고르고 공격할 때, 실제로 플레이한다면 보통 이미 다른 플레이어가 공격 중인 몬스터는 공격하지 않습니다. 이런 행동을 흉내 내려면 여러 상태에서 현재 타겟이 여전히 유효한지 확인하는 것이 중요합니다. 또한 모든 예외 상황을 고려하는 것도 중요합니다. 타겟팅이 끝났을 때는 이미 다른 플레이어가 한 방에 잡아 버린 몬스터를 봇이 선택했을 수도 있는데, 이런 경우에 봇이 멈춰 버리면 안 됩니다. 그래서 Choosing, Target, Targeted, Engage, Engaged 상태를 따로 두었습니다.
즉, 각 상태는 자체적인 검사 항목을 가지고 있으며 모든 검사를 통과했을 때만 행동을 수행하고, 그렇지 않으면 Start로 돌아갑니다.
"Cannot see target"(대상이 보이지 않음)이나 "Invalid target"(잘못된 대상) 같은 시스템 메시지 등의 이벤트를 받으면, 오토마타가 현재 어떤 상태인지 확인하고 그에 맞게 반응하도록 단순하게 처리했습니다.
결과
결과적으로 봇은 잘 동작하며, 몇 시간 동안 파밍을 해도 시도해 본 어떤 서버에서도 탐지되지 않았습니다. 같은 컴퓨터, 같은 서버에서 인스턴스 4개를 동시에 몇 시간 동안 아무 문제 없이 돌릴 수 있었는데, 다른 봇 도구들은 바로 탐지된다는 것을 알고 있습니다. 이를 보면 온라인에서 구매할 수 있는 "안티 봇" 시스템들은 사실 알려진 봇이나 시그니처만 검사할 뿐, "고급" 분석은 시도조차 하지 않는 것 같습니다.
일부 서버는 더 정교해서 일정 시간이 지나면 캡차를 요구한다는 것도 알고 있습니다. 이런 것들은 서버가 자체 제작한 보호 장치라서 플레이어에게는 다소 귀찮을 수 있지만, 봇 사용자 입장에서는 뚫기가 더 어렵습니다. (그래도 온라인에서 실제로 작동하는 인게임 캡차 해독 구현을 몇 개 찾긴 했습니다.)
이 정도입니다. 결국 봇은 꽤 간단하게 만들 수 있었고, 아직 할 일이 많지만 결과에 만족합니다. 다만 향수가 사라져 버려서 이 작은 프로젝트에 더 많은 시간을 쓰지는 않을 것 같습니다.
소스 코드는 제 GitHub 저장소에서 받을 수 있습니다: https://github.com/xarkes/L2Bot.
리니지2 봇 GUI
봇에는 아직 개선할 점이 많습니다. 관심이 있다면 다음 섹션을 확인해 보세요.
더 나아가기
지금의 봇으로도 일단 동작은 하지만, 멍청한 봇을 좋아하는 사람은 없습니다. 그래서 봇을 어떤 상황에서든 쓸 수 있으려면 있으면 좋을 것 같은 기능들을 아래에 정리했습니다.
- 리니지2 버전 자동 감지 및 여러 프로토콜 버전 제대로 지원하기
- 자동 스포일/스윕 기능 지원
- 셀프 버프 지원
- 파티 버프 지원
- 아이템 사용 지원 (물약, 주문서 등)
- 아이템/버프/스킬 사용 조건 추가
- 게임 데이터를 자동으로 파싱해서 아이템과 스킬 아이콘, NPC 이름 등을 가져와 표시하기
- 재미있는 사실: 리니지2의
.dat파일은 RSA로 암호화되어 있습니다. 즉, 클라이언트는 공개 키로 복호화할 수 있지만, 유효한.dat파일은 게임 퍼블리셔만 만들 수 있습니다 (RSA 키가 너무 약하지 않다면요). - 사실상 서명인 셈인데, 복호화 결과가 올바른 파일 형식인지만 확인한다는 점이
지저분하네요재미있네요
- 재미있는 사실: 리니지2의
- 사망 시 행동 추가 (마을로 귀환, 접속 종료 등)
- UI 개선 (더 나은 지도 크기 조절 등)
- 더 나은 파티 상호작용 (필요할 때 다른 캐릭터를 어시스트하거나, 버프나 힐을 해 주는 등)
- 스크립팅 엔진 추가
- 행동을 녹화해서 일부 퀘스트나 기타 여러 작업을 자동화할 수 있다면 정말 멋질 것 같습니다.
- 몬스터를 공격하려 할 때 캐릭터의 시야를 가리는 장애물을 피하기 위한 "진입 금지" 구역 기능 추가
여기까지입니다. 궁금한 점이 있으면 언제든 연락 주세요. 재미있게 읽으셨길 바랍니다!