본문 바로가기
게임 보안/[서적] 봇을 이용한 게임 해킹

5. 게임 메모리 읽고 쓰기

by 헛둘이 2022. 9. 29.

본 게시글은 메모리에 대한 깊은 이해를 위해 에이콘 출판사의 "봇을 이용한 게임 해킹" 서적을 보고 필기한 자료입니다.

따라서 디테일한 부분에서 본 서적의 실제 내용과 다를 수 있고 글쓴이의 주관이 들어가 있음을 참고해주시기 바랍니다.

문제 시 비공개 처리하도록 하겠습니다.

 

코드를 작성할 때 메모리가 어떻게 동작하는지 늘 염두에 두고 있어야 한다.

 

게임 프로세스 식별자 알아내기
  • 게임 메모리에 무언가 쓰려면 프로세스 식별자 (PID)를 알아야 한다.
  • PID는 활성화된 프로세스를 구별할 수 있는 고유 번호
  • 시각적으로 확인 가능한 창이라면 GetWindowThreadProcessId()로 PID를 구할 수 있다.

출처 : msdn

  • 첫 번째 파라미터로 창의 핸들을 가짐
  • 두 번째 파라미터로는 PID를 받을 DWORD 변수의 포인터를 받음 (여기로 값이 반환됨)
#include <iostream>
#include <Windows.h>

int main()
{
	HWND myWindow =
		FindWindow(NULL, L"윈도우 타이틀");

	DWORD PID;
	GetWindowThreadProcessId(myWindow, &PID);
}

 

  • 만약 게임이 창으로 표시되지 않거나, 창 이름을 모른다면 모든 프로세스를 살펴봐서 찾아내야 함
  • tlhelp32.h의 CreateToolhelp32SnapShot(), Process32First(), Process32Next()를 이용해서 수행함

 

#include <iostream>
#include <string>
#include <Windows.h>
#include <TlHelp32.h>

int main()
{
	PROCESSENTRY32 entry;
	entry.dwSize = sizeof(PROCESSENTRY32);
	HANDLE snapshot =
		CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, NULL);

	if (Process32First(snapshot, &entry))
	{
		while (Process32Next(snapshot, &entry))
		{
			std::wstring binPath = entry.szExeFile;

			if (binPath.find(L"game.exe") != std::wstring::npos)
			{
				printf("game pid is %d\n", entry.th32ProcessID);
				break;
			}
		}
	}

	CloseHandle(snapshot);
}

 

 

 

 

 


프로세스 핸들 알아내기
  • PID를 알아냈다면 OpenProcess()라는 함수를 사용해 프로세스 핸들을 구할 수 있다.
  • 이 함수를 사용하면 해당 프로세스의 메모리를 읽고 쓴느 접근 수준의 핸들을 가져올 수 있음
  • 프로세스상에서 수행되는 모든 함수는 접근 권한을 요구하기 때문에 게임 해킹에서 핵심적인 부분임

  • 첫 번째 파라미터 DesiredAccess는 프로세스 액세스 플래그임
  • 다양한 플래그가 있는데 자주 사용되는 플래그는 아래와 같다.
  • 두 번째 파라미터는 핸들 상속에 대한 내용인데 통상 false
  • 세 번째 파라미터는 위에서 구한 PID를 전달하면 된다.
프로세스 액세스 플래그 설명 같이 사용되는 함수
PROCESS_VM_OPERATION 가상 메모리 할당, 해제, 보호하기 위한 권한을 가짐 VirtualAllocEx
VirtualFreeEx
VirtualProtectEx
PROCESS_VM_READ 가상 메모리 읽기 권한 ReadProcessMemory
PROCESS_VM_WRITE 가상 메모리 쓰기 권한 WriteProcessMemory
PROCESS_CREATE_THREAD 쓰레드 생성 권한 CreateRemoteThread
PROCESS_ALL_ACCESS 모든 권한 이전 버전의 윈도우와 호환성 문제가 있으므로
사용하지 않는게 좋다.
DWORD PID = getGamePID();
	
HANDLE process = OpenProcess(
	PROCESS_VM_OPERATION |
	PROCESS_VM_READ |
	PROCESS_VM_WRITE,
	false,
	PID
);

if (process == INVALID_HANDLE_VALUE) {
	printf("Failed to open PID %d, error code %d", PID, GetLastError());
}
  • 읽고 쓰기 권한을 가진 핸들을 구하는 예제

 

 

 

 

 


메모리 접근
  • 윈도우API에는 메모리 접근에 핵심적인 2개의 함수가 존재한다.
  • ReadProcessMemory()와 WriteProcessMemory()임
  • 이 두 함수를 통해 게임 메모리를 조작할 수 있다.

 

  • 두 함수는 서로 대놓고 닮아있다.
  • 그래서 이들을 활용하는 작업도 거의 동일하다.
  • 첫 번째 파라미터는 프로세스의 핸들
  • 두 번째 파라미터는 타깃 메모리 주소
  • 세 번째 파라미터는 쓰고 있는 데이터 (Read는 내 버퍼, Write는 상대 버퍼)
  • 네 번째 파라미터는 Buffer의 크기를 바이트단위로 정의
  • 마지막 파라미터는 액세스된 바이트의 규모를 반환, NULL로 세팅해도 됨

 

DWORD val;
ReadProcessMemory(process, adr, &val, sizeof(DWORD), 0);
printf("Current mem value is %d\n", val);

val++;

WriteProcessMemory(process, adr, &val, sizeof(DWORD), 0);
ReadProcessMemory(process, adr, &val, sizeof(DWORD), 0);
printf("New mem value is confirmed as %d\n", val);
  • 이 코드를 사용하기 전에 읽고 쓰고자 하는 메모리의 주소를 미리 알아내야 함
  • 이 값들이 제대로 배치되면
  1.  ReadProcessMemory() 함수는 읽어온 메모리를 val에 저장함
  2. val을 증가시키고 WriteProcessMemory()를 통해 원래 값을 대체함
  3. 그 다음 ReadProcessMemory()로 확인함

 

정형화된 메모리 액세스 함수 작성하기
  • 위에서 읽어올 데이터가 DWORD형이란걸 이미 알고 있었다
  • 다재다능한 해커는 범용 코드에 익숙해지는게 좋다.
template<typename T>
T readMemory(HANDLE proc, LPVOID adr)
{
	T val; 
	ReadProcessMemory(proc, adr, &val, sizeof(T), NULL);
	return val;
}

template<typename T>
void writeMemory(HANDLE proc, LPVOID adr, T val)
{
	WriteProcessMemory(proc, adr, &val, sizeof(T), NULL);
}
  • 각기 다른 타입에 대해 readMemory, writeMemory를 만들지 않아도 된다는게 큰 장점이다.

 

 

 

 


메모리 보호
  • 윈도우가 게임이나 일반 프로그램에 할당되면 페이지(Page)안에 위치하게 된다.
  • x86 윈도우에서 페이지는 4096바이트의 데이터 저장소임
  • 모든 메모리가 페이지 안에 위치해야 하므로 최소 단위 또한 4096 바이트다.
할당된 메모리 중 사용되지 않은 충분한 공간이 있는 경우 더 적은 메모리를 설정할 수 있다.
  • 운영체제에서 메모리를 할당하는 경우 할당된 페이지에 여유가 있는지부터 본다.
  • 필요하면 새 페이지를 할당
  • 메모리 페이지는 독특한 속성을 갖고 있음
  • 메모리 관련 작업을 수행할 때 신경써야할 속성이 바로 보호 속성이다.

 

 

 

 

x86 윈도우 메모리 보호 속성 구별하기
  • 우리가 배운 메모리 읽는 기술은 아주 기본적인 것임
  • 우리가 살펴본 메모리는 PAGE_READWRITE 속성에 의해 보호받고 있다고 간주되었던 것
  • 실제 페이지 상에 다양한 보호속성을 가진 데이터도 존재함
페이지 보호 속성 플래그 읽기/쓰기/실행 (권한)
PAGE_NOACCESS X/X/X
PAGE_READONLY O/X/X
PAGE_READWRITE O/O/X
PAGE_WRITECOPY O/O/X
PAGE_EXECUTE X/X/O
PAGE_EXECUTE_READ O/X/O
PAGE_EXECUTE_READWRITE O/O/O
PAGE_EXECUTE_WRITECOPY O/O/O
  • 자주 쓰이는 것들 BOLD처리함

 

 

 


메모리 보호 변경
  • 가끔 메모리 페이지 보호로 접근이 금지된 메모리를 액세스해야 하는 경우가 있음
  • 이럴 때 보호 모드를 마음대로 변경할 수 있어야 한다.
  • VirtualProtectEx 함수를 사용하면 됨

  • flNewProtect 인자를 통해 새로운 메모리 보호 플래그를 받고, lpflOldProtect를 통해 이전의 보호 모드를 반환 받음
  • 대부분 메모리 보호는 페이지 단위로 수행됨
  • 메모리 페이지 권한을 변경하는 코드도 정형화시키면 좋다.
template<typename T>
DWORD protectMemory(HANDLE proc, LPVOID adr, DWORD prot)
{
	DWORD oldProt;
	VirtualProtectEx(proc, adr, sizeof(T), prot, &oldProt);
	return oldProt;
}
protectMemory<DWORD>(process, address, PAGE_READWRITE);
writeMemory<DWORD>(process, address, newValue);
  • 메모리 보호 모드를 PAGE_READWRITE로 변경하고 메모리를 쓰는 함수를 호출하는 예제
  • 게임이 침입자를 감지하지 못하게 변경된 보호속성을 최소 기간만 유지하고 원래대로 복구하는게 좋다.

 

DWORD oldProt =
	protectMemory<DWORD>(process, address, PAGE_READWRITE);
writeMemory<DWORD>(process, address, newValue);
protectMemory<DWORD>(process, address, oldPlot);
  • 전형적인 쓰기 관련 코드
  • 위 코드는 페이지의 보호 속성을 변경하고, 그 메모리에 newValue를 쓴 후 보호 속성을 원래대로 복구하는 코드

 

 

메모리 조작 관련하여 중요한 사실
  • 게임의 메모리를 조작하고 있을 때 게임에서도 동일 메모리에 접근할 수 있다.
  • 만약 새로 설정한 보호 모드가 원래 보호 모드와 호환되지 않으면 ACCESS_VIOLATION 예외로 처리하고 크래시
  • 예시로 PAGE_EXECUTE를 PAGE_READWRITE로 변경했다면 게임은 페이지 상 코드를 실행하려 하지만
  • 보호속성 때문에 크래시 발생
  • 이런 경우 PAGE_EXECUTE_READWRITE로 변경하면 되긴 함

 

 

 

 

 

 

 

 


주소공간 배치 난수화(ASLR)
  • 바이너리가 ASLR을 지원하는 기능을 가지고 컴파일된다면 실행할 때마다 기본 주소가 달라짐
  • (MSVC++ 2010과 다수 컴파일러에서 기본값)
  • ASLR을 지원하지 않는 바이너리는 기본 주소 0x400000을 갖는다.
DWORD rebase(DWORD address, DWORD newBase)
{
	DWORD diff = address - 0x400000;
	return diff + newBase;
}
  • 위 함수에서 newBase를 찾아내기 위해선 GetModuleHandle() 함수를 사용하면 됨
  • 매개변수가 NULL일 경우, 이 함수는 항상 프로세스 메인 핸들을 반환함
  • 이 값은 바이너리가 매핑된 주소를 가리킴
  • 이 값이 기본 주소이기 때문에 이를 DWORD로 설정해서 newBase를 얻을 수 있다.
  • 실제로는 다른 주소의 기본 주소를 찾아야 함
  • 이를 위해 CreateRemoteThread 함수를 사용한다.
  • CreateRemoteThread 함수를 통해 스레드를 생성하고 원격 프로세스상에서 코드를 수행함

  • 생성된 스레드는 StartAddress를 통해 시작됨
  • Param을 매개변수로 가진 함수처럼 동작하고, 스레드 종료 코드로 값을 반환함
  1. 스레드가 GetModuleHandle() 주소를 가리키는 StartAddress에서 시작
  2. WaitForSingleObject()로 스레드가 끝나기를 기다림
  3. GetExitCodeThread()로 결과 값(기본 주소) 반환

 

DWORD newBase;

HMODULE k32 = GetModuleHandle(L"kernel32.dll");

LPVOID funcAdr = GetProcAddress(k32, "GetModuleHandleA");
if (!funcAdr)
	funcAdr = GetProcAddress(k32, "GetModuleHandleW");

HANDLE thread =
	CreateRemoteThread(process, NULL, NULL,
		(LPTHREAD_START_ROUTINE)funcAdr,
		NULL, NULL, NULL);

WaitForSingleObject(thread, INFINITE);
GetExitCodeThread(thread, &newBase);

CloseHandle(thread);
  • GetModuleHandle은 kernel32.dll에 포함되어 있는데
  • 이 dll 주소는 모든 프로세스에서 동일하기 때문에 외부 봇에서도 동일함
  • 따라서 GetProcAddress에서 가져온 주소도 타깃 프로세스의 주소와 동일함
  • 내 함수가 찾은 GetProcAddress의 주소가 타깃 프로세스에서도 유효하다는 소리임

 

댓글