게임 보안/[서적] 봇을 이용한 게임 해킹
7. 게임 플로우 조작하기
헛둘이
2022. 10. 4. 12:43
본 게시글은 메모리에 대한 깊은 이해를 위해 에이콘 출판사의 "봇을 이용한 게임 해킹" 서적을 보고 필기한 자료입니다.
따라서 디테일한 부분에서 본 서적의 실제 내용과 다를 수 있고 글쓴이의 주관이 들어가 있음을 참고해주시기 바랍니다.
문제 시 비공개 처리하도록 하겠습니다.
후킹이란?
- 코드가 수행되는 과정에 개입해 프로세스를 변경하고 모니터링을 수행한 후 이를 원상태로 돌아가지 않게 방지하는 기술
- 프로세스의 모든 코드를 삭제하거나 실행 순서를 변경해 주입된 함수를 실행하도록 하는 것
NOP을 통해 코드 삭제하기
- NOP을 사용해야 하는 경우?
- 투명화된 적을 상대해야 하는데 그 적이 체력바도 보이지 않아 접근하는지조자 알 수 없을 때
for (int i = 0; i < creatures.size(); i++)
{
auto c = creatures[i];
if (c.isEnemy && c.isCloaked)
continue;
drawHealthBar(c.healthBar);
}
- 범위 안의 적을 for문을 돌면서 투명상태인지 확인하고 투명이 아니면 체력바를 표시하는 코드
- continue 구문을 삭제함으로써 목적을 달성할 수 있다.
- 위 코드의 대략적인 어셈블리 구조
startOfLoop:
MOV i, 0
JMP condition
increment:
ADD i, 1
condition:
CMP i, creatures.Size()
JNB endOfLoop
body:
MOV c, creatures[i]
TEST c.isEnemy, c.isEnemy
JZ drawHealthBar
TEST c.isCloaked, c.isCloaked
JZ drawHealthBar
JMP increment
drawHealthBar:
CALL drawHealthBar(c.healthBar)
JMP increment
endOfLoop:
- 여기서 JMP increment를 NOP으로 덮어씀으로써 목적을 달성할 수 있다.
NOP을 사용하는 방법
- NOP 명령어를 쓰는 동안 게임이 이 코드를 실행할 수 있으므로 접근권한을 PAGE_EXECUTE_READWRITE로 변경해놓을 필요가 있다.
- writeNop 함수는 메모리 보호 모드를 조절해서 사이즈만큼 NOP을 쓰고 복구하는 역할을 한다.
template<int SIZE>
void writeNop(DWORD address)
{
auto oldProtection =
protectMemory<BYTE[SIZE]>(addreess, PAGE_EXECUTE_READWRITE);
for (int i = 0; i < SIZE; i++)
{
writeMemory<BYTE>(address + 1, 0x90);
}
}
- 사이즈를 템플릿 파라미터로 가져오는 이유는 컴파일 시 사이즈가 결정되도록 하여 적절히 보호하려는 의도
writeNop<2>(0xDEADBEEF);
- 위 코드를 통해 NOP 명령어의 횟수를 가지고 함수를 호출할 수 있다.
- NOP 명령어의 수가 제거하려는 명령어의 수와 일치해야 함
#include <iostream>
#include <Windows.h>
DWORD testLoop(LPVOID lpParam)
{
int i = 5;
while (true)
{
std::cout << "jump to origin" << std::endl;
Sleep(1000);
}
std::cout << "overwrite jump operation!!";
return 0;
}
int main()
{
//testLoop(0);
HANDLE handle = OpenProcess(PROCESS_ALL_ACCESS, false, GetCurrentProcessId());
HANDLE thread = CreateThread(0, 0, (LPTHREAD_START_ROUTINE)testLoop, 0, 0, 0);
Sleep(5000);
//DWORD* funcAddr = (DWORD*)&testLoop;
DWORD oldProtect;
VirtualProtectEx(handle, &testLoop, 1000, PAGE_EXECUTE_READWRITE, &oldProtect);
DWORD readMem;
ReadProcessMemory(handle, (LPVOID)((DWORD)testLoop + 1), &readMem, 4, NULL);
DWORD orgFuncAddr = (DWORD)testLoop + readMem + 5;
BYTE* mem = (BYTE*)orgFuncAddr;
DWORD writeOldProtect;
VirtualProtectEx(handle, mem, 1000, PAGE_EXECUTE_READWRITE, &writeOldProtect);
BYTE* jmpAddr = nullptr;
for (int i = 0; i < 1000; i++)
{
if (mem[i] == 0xEB && mem[i + 1] == 0xB8)
{
std::cout << "find jmp address!!" << (DWORD*)&mem[i] << std::endl;
mem[i] = 0x90; // nop으로 덮어쓰기
mem[i + 1] = 0x90; // nop으로 덮어쓰기
}
}
VirtualProtectEx(handle, &testLoop, 1000, oldProtect, NULL);
VirtualProtectEx(handle, mem, 1000, writeOldProtect, NULL);
while (1)
{
}
}
- 삽질의 흔적들
- 바이트로 변환된 opcode를 다루는 게 익숙치 않아서 함수포인터가 가리키는 주소로 어떻게 이동할지 많이 생각해봤는데
- 결국은 CALL 다음 4바이트를 가져와서 읽어버리면 되는 거였다..
- "overwrite jump operation!!"이 출력되려면 JMP가 NOP으로 덮어씌워져야 하는데 잘 출력되는걸 볼 수 있다.
콜 후킹
- 콜 후킹은 콜 인스트럭션의 피연산자를 수정하여 원하는 코드로 이동하는 것
- CALL의 피연산자로 오프셋을 넘겨 가까운 주소로 근거리 점프함
CALL 0x0BADF00D
- 근거리 호출은 E8 바이트로부터 수행되므로 0xE8 0x0BADF00D가 되고,
- 이걸 리틀 엔디안으로 바꾸면 아래와 같다.
0xE8 0x0B 0xF0 0xAD 0x0B
- 근거리 호출은 이렇게 총 5바이트로 처리되며, 호출 이후 주소는 메모리상에서 5바이트 뒤가 된다.
근거리 호출은 '호출이 발생'하면 즉시 주소의 '상대적인 피호출자의 오프셋'을 저장함
- 근거리 호출 후킹의 원리는
- 함수 호출 시 함수로 바로 가지 않고 5바이트 offset 배열로 간다고 이전 글에 포스팅한 바 있다.
- 그 원래 함수를 가리키는 offset의 4바이트를 변경할 함수의 offset으로 덮어쓰는 것이다.
int OrgFunc()
{
std::cout << "im org func" << std::endl;
return 0;
}
int FakeFunc()
{
std::cout << "im fake func!!!" << std::endl;
return 0;
}
int main()
{
HANDLE process = OpenProcess(PROCESS_ALL_ACCESS,
false, GetCurrentProcessId());
DWORD orgAddr = (DWORD)OrgFunc;
DWORD fakeAddr = (DWORD)FakeFunc;
DWORD newOffset = fakeAddr - orgAddr - 5;
DWORD oldProtection;
VirtualProtectEx(process, (LPVOID)(orgAddr + 1), 1000, PAGE_EXECUTE_READWRITE, &oldProtection);
DWORD orgOffset = readMemory<DWORD>(orgAddr + 1);
writeMemory<DWORD>(orgAddr + 1, newOffset);
protectMemory<DWORD>(orgAddr + 1, oldProtection);
DWORD orgorgAddr = orgOffset + orgAddr + 5;
OrgFunc();
FakeFunc();
int(*f)() = (int(*)())orgorgAddr;
f();
}
- 모든 동작을 마치고 f를 실행하면 FakeFunc가 실행되는 것을 볼 수 있다.
스택 비우기
- 덮어쓴 새로운 함수는 기존 함수처럼 스택을 정리해야 한다.
- 이는 곧 함수호출규약과 인수 정리하는 로직이 기존 함수와 동일해야 함을 의미한다.
PUSH 1
PUSH 456
PUSH 321
CALL 0x0BADF00D
ADD ESP, 0x0C
- 이 경우 호출자에 의해 스택이 정리되므로 __cdecl 호출 규약을 따른다고 볼 수 있다.
- 0x0C를 4로 나누면 3이 나오므로 인수는 3개라는 것을 알 수 있다.
- 혹은 PUSH 명령의 개수를 보고 인자 개수를 알 수도 있음
콜 훅 작성
- 새롭게 덮어쓸 함수도 같은 호출규약과 같은 인자 개수를 가지고 있게끔 만든다
DWORD __cdecl someNewFunction(DWORD arg1, DWORD arg2, DWORD arg3)
{
// 원하는 기능 구현
}
- __cdecl은 Visual Studio에서 기본값이므로 굳이 안적어도 되지만 되도록이면 상세하게 적어주는 것이 좋다.
- 대부분 후킹은 원래 함수를 호출하고 값을 정상적으로 반환함으로써 마무리 됨
typedef DWORD(__cdecl _origFunc)(DWORD arg1, DWORD arg2, DWORD arg3);
_origFunc* originalFunction = (_origFunc*)hookCall(0xDEADBEEF, (DWORD)&someNewFunction);
DWORD __cdecl someNewFunction(DWORD arg1, DWORD arg2, DWORD arg3)
{
return originalFunction(arg1, arg2, arg3);
}
- 나중에 someNewFunction에 코드를 더 추가함으로써 많은 일을 할 수가 있음
- 전달되는 인수를 수정한다던가, 가로챈다던가 등..
가상함수 테이블(VFTABLE) 후킹
- 클래스 인스턴스는 내부적으로 정적 가상함수 테이블을 공유한다.
- 이 테이블을 덮어씀으로써 인스턴스가 어떤 함수를 호출하던 인터셉트할 수 있는 후킹 기술
- VFTABLE은 타입이 정확하게 처리되지 않으면서 가상 함수가 호출될 때 검색된다.
- VFTABLE 후킹을 구현하기 전에 후킹하려는 함수 호출이 모호한지 먼저 살펴봐야 함
훅 작성
- VFTABLE이 사용하는 함수 호출규약부터 살펴봐야 함
- 모든 멤버함수가 인스턴스를 통해 액세스되며, __thiscall 호출규약을 준수한다.
- __thiscall의 this는 인스턴스를 가리키는 포인터 이름 this에서 따왔다.
- 따라서 멤버함수에게 ECX의 의사파라미터로 this가 주어짐
- VFTABLE 후킹을 위해 대상과 비슷한 형식의 클래스를 선언할 수도 있지만 더 좋은 방법이 있음
class someBaseClass
{
public:
virtual DWORD someFunction(DWORD arg1) {}
};
class someClass : public someBaseClass
{
public:
virtual DWORD someFunction(DWORD arg1) {}
};
- someFunction이라는 함수를 후킹하려고 한다면
DWORD __stdcall someNewVFFunction(DWORD arg1)
{
static DWORD _this;
__asm MOV _this, ECX
}
- 이와 같이 접근할 수 있다.
- __thiscall과 __stdcall의 유일한 차이점은 ECX로 this가 주어지나먀냐의 차이임
- ECX가 수행되기 전에 어셈블리상에서 진행되는 코드는 스택 프레임을 초기화하는 코드 뿐이기 때문에
- _this는 정상적으로 this를 가질 수 있게 됨
DWORD __stdcall someNewVFFunction(DWORD arg1)
{
static DWORD _this;
__asm MOV _this, ECX
// 수정
__asm MOV ECX, _this
}
- VFTABLE 훅에서 원래 함수를 호출하려면 반드시 인라인 어셈블리를 사용해야 함
- 그게 this를 처리할 수 있는 가장 적절한 방법이기 때문
DWORD __stdcall someNewVFFunction(DWORD arg1)
{
static DWORD _this, _ret;
__asm MOV _this, ECX
__asm {
PUSH arg1
MOV ECX, _this
CALL [originalFVFunction]
MOV _ret, EAX
}
__asm MOV ECX, _this
return _ret;
}
- this를 통해 오리지널 함수를 부르는 것을 볼 수 있다.
- __thiscall은 __stdcall과 같이 피호출자에서 인수를 정리하므로 따로 정리할 필요가 없음
- 원래 반환값을 _ret에 담아서 반환하는 것을 볼 수 있다.
VFTABLE 후킹 활용하기
- VFTABLE 후킹을 성공시키려면 인스턴스와 가상함수 테이블에서 그 함수의 인덱스를 알아야 한다.
DWORD hookVF(DWORD classInst, DWORD funcIndex, DWORD newFunc)
{
DWORD VFTable = *((DWORD*)(classInst));
DWORD hookAt = VFTable + funcIndex * sizeof(DWORD);
DWORD oldProt;
HANDLE process = OpenProcess(PROCESS_ALL_ACCESS, false, GetCurrentProcessId());
VirtualProtectEx(process, (LPVOID)hookAt, 1000, PAGE_READWRITE, &oldProt);
DWORD orgFunc = *((DWORD*)hookAt);
WriteProcessMemory(process, (LPVOID)hookAt, (LPVOID)newFunc, 4, NULL);
VirtualProtectEx(process, (LPVOID)hookAt, 1000, oldProt, NULL);
CloseHandle(process);
return orgFunc;
}
- 요약하자면 VTABLE이 가리키는 주소에 내 함수 주소를 적는 로직
IAT 후킹
- IAT란? API가 다른 API를 호출하기 위한 룩업 테이블이라고 보면 됨
- 각 모듈마다 PE헤더에 IAT가 하나씩 박혀 있음
- 이 모듈의 IAT에는 모듈과 의존관계인 함수와, 관계있는 다른 모듈의 코드가 포함되어 있다.
이식성에 대한 비용
- IAT에서 실시간으로 함수 주소를 가져오는 것
- 함수 주소는 이름과 함께 저장되기 때문에 함수 이름만 알면 주소를 찾을 수 있다.
- 따라서 윈도우에서 사용하는 API 함수들은 거의 후킹 가능하다고 보면 됨
- 이식성에 대한 비용은 절차와 코드의 복잡성이다.
PE헤더 찾기 및 검증
DWORD baseAddr = (DWORD)GetModuleHandle(NULL);
- GetModuleHandle에 0을 인자로 넘겨주면 현재 모듈의 시작 주소를 가져온다
- 일부 게임에선 PE 헤더가 로드된 다음 중요하지 않은 부분은 섞어버리는 경우가 있음
- 따라서 PE 헤더가 유효한지 검증은 꼭 필요하다
- 제대로 된 PE헤더는 DOS헤더에 의해 0x5A4D라는 접두사가 부여됨 (매직값이라고 부름)
- DOS헤더의 멤버인 e_lfaenew라는 헤더는 옵션 헤더라고 불리는데 0x10B로 구별이 가능
- 윈도우 API는 IMAGE_DOS_HEADER와 IMAGE_OPTIONAL_HEADER라는 PE 구조체를 갖고 있다.
- 각각 DOS헤더와 옵션 헤더에 대응하며, 얘네를 통해 검증이 가능하다.
DWORD hMod = (DWORD)GetModuleHandle(0);
auto dosHeader = pointMemory<IMAGE_DOS_HEADER>((LPVOID)hMod);
if (dosHeader->e_magic != 0x5A4D)
return 0;
auto optHeader = pointMemory<IMAGE_OPTIONAL_HEADER>((LPVOID)(hMod + dosHeader->e_lfanew + 24));
if (optHeader->Magic != 0x10B)
return 0;
- 어셈블리는 IAT의 주소가 하드코딩되어 있으므로 PE헤더를 참조하지 않음
- 대신 함수 호출은 함수 주소를 가리키고 있는 정적 주소에서 수행됨
- IAT후킹이 PE헤더를 덮어쓴다는 것 때문에 아예 PE헤더 로딩을 하지 않는 게임들도 있다고 함
- 아래는 IAT 검증 코드
auto IAT = optHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
if (IAT.Size == 0 || IAT.VirtualAddress == 0)
return 0;
- 옵션 헤더의 멤버인 DataDirectory 멤버에는 각종 데이터 섹션의 크기와 가상 주소가 들어 있음
- IMAGE_DIRECTORY_ENTRY_IMPORT는 윈도우에서 정의한 상수로 DataDirectory에서 IAT의 인덱스에 해당함
IAT 트래버싱
- IAT는 IMPORT_DESCRIPTOR라고 부르는 구조체의 배열이라고도 볼 수 있다.
- IMPORT_DESCRIPTOR는 THUNK라는 구조체 배열을 가리킨다.
- 각 THUNK는 DLL에서 임포트된 함수를 표현함
IMAGE_IMPORT_DESCRIPTOR란?
- PE파일 자신이 어떤 라이브러리(DLL)을 임포트하고 있는지에 대한 정보를 담고 있는 구조체
- 그림판에서 임포트하는 DLL이 4개라고 한다면 IMAGE_IMPORT_DESCRIPTOR의 멤버는 5개(DLL 4개 + NULL 1개)
- IAT의 마지막은 NULL로 채워져 있음
- 윈도우 API는 IMAGE_IMPORT_DESCRIPTOR와 IMAGE_THUNK_DATA 구조체를 통해
- IID와 THUNK에 접근할 수 있다.
auto impDesc = pointMemory<IMAGE_IMPORT_DESCRIPTOR>((LPVOID)(hMod + IAT.VirtualAddress));
// IMAGE_IMPORT_DESCRIPTOR의 FirstThunk의 마지막 요소는 NULL이므로 끝까지 순회하고 종료함
while (impDesc->FirstThunk)
{
auto thunkData = pointMemory<IMAGE_THUNK_DATA>((LPVOID)(hMod + impDesc->OriginalFirstThunk));
int n = 0;
// Function 멤버도 마지막 요소가 NULL이므로 순회하고 종료됨
while (thunkData->u1.Function)
{
//hook Code
n++;
thunkData++;
}
impDesc++;
}
IAT 훅 설치 위치 정하기
- 적당한 함수명을 찾아내면 거기서 멈추고 원하는 함수로 대체할 수 있다.
- 함수명은 아래 코드로 찾아낼 수 있다.
char* importFuncName = pointMemory<char>((LPVOID)(hMod + (DWORD)thunkData->u1.AddressOfData + 2));
if (strcmp(importFuncName, "MessageBoxA") == 0)
{
}
- 위 방법도 물론 좋지만 이전 ecourse에서 배운 내용으로 코드를 짜는게 좋을 것 같다.
#include <iostream>
#include <Windows.h>
#include <DbgHelp.h>
#pragma comment(lib, "DbgHelp.lib")
template<typename T>
T* pointMemory(LPVOID addr)
{
return (T*)addr;
}
DWORD __stdcall fakeFunction(UINT a, LPCSTR b, LPCSTR c, UINT d)
{
std::cout << "fake!!" << std::endl;
return 0;
}
int main()
{
auto func = MessageBoxA;
ULONG sz;
HMODULE hExe = GetModuleHandle(NULL);
IMAGE_IMPORT_DESCRIPTOR* pImage =
(IMAGE_IMPORT_DESCRIPTOR*)ImageDirectoryEntryToData(
hExe, TRUE, IMAGE_DIRECTORY_ENTRY_IMPORT, &sz);
if (pImage == NULL)
{
std::cout << "not find image directory.." << std::endl;
return 0;
}
while (pImage->Name)
{
IMAGE_THUNK_DATA* pThunk = pointMemory<IMAGE_THUNK_DATA>((BYTE*)hExe + pImage->FirstThunk);
while (pThunk->u1.Function)
{
if ((LPVOID)pThunk->u1.Function == MessageBoxA)
{
std::cout << "findFunc!!" << std::endl;
DWORD oldProtect = 0;
DWORD orgFuncAddr = pThunk->u1.Function;
VirtualProtect(pThunk, sizeof(DWORD), PAGE_READWRITE, &oldProtect);
pThunk->u1.Function = (DWORD)fakeFunction;
VirtualProtect(pThunk, sizeof(DWORD), oldProtect, NULL);
goto end;
}
pThunk++;
}
pImage++;
}
end:
MessageBoxA(0, "hh", "gg", MB_OK);
return 0;
}
IAT 훅을 사용해서 게임 스레드와 싱크하기
- 위 코드를 사용하면 어떤 API든 후킹할 수 있음
- 게임에서 가장 많이 사용하는 건 스레드 싱크를 맞추기 위한 Sleep 함수
- hookIAT를 활용하는 방법은 아래와 같다.
VOID WINAPI newSleepFunction(DWORD ms)
{
originalSleep(ms);
}
typedef VOID(WINAPI _origSleep)(DWORD ms);
_origSleep* originalSleep = (_origSleep*)hookIAT(Sleep, (DWORD)&newSleepFunction);