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

6. 코드 인젝션

by 헛둘이 2022. 10. 1.

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

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

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

 

코드 인젝션이란 프로세스가 수행되는 메모리 공간과 실행 환경에서 외부 코드를 수행하도록 강제하는 것

 

코드 인젝션 방법
  • 코드 케이브를 만들고 스레드를 주입하는 방법
  • 직접 외부 바이너리를 이식하는 것

 

 

스레드 인젝션으로 코드 케이브 주입하기
  • 다른 프로세스에 코드를 주입하는 첫 단계는 셸 코드
  • 셸 코드란? 위치와 상관없이 수행 가능한 어셈블리 코드를 바이트 배열 형태로 작성한 것
  • 셸 코드를 작성해 코드 케이브를 만들 수 있고, 코드 케이브는 새 스레드의 시작주소 역할
  • 그 후 스레드 인젝션 혹은 스레드 하이재킹으로 실행-

 

CreateRemoteThread를 통한 코드 인젝션
DWORD __cdecl someFunction(int times, const char* string)
  • 이런 형태의 함수를 원격으로 실행한다면?
  • CreateRemoteThread를 통해 인자 하나는 전달이 가능하겠지만 다른 하나는 코드 케이브에 하드코딩해야 함
  • 둘 중 간단한 인자를 하드코딩하는 것이 셸 코드의 크기도 줄이고 여러 이점이 있음
PUSH DWORD PTR:[ESP+0x4]
PUSH times
MOV EAX, someFunction
CALL EAX
ADD ESP, 0x8
RETN
  • 반환값이 EAX를 덮어쓰므로 EAX에 중요 데이터가 저장되지 않게 할 필요가 있음

 

어셈블리를 셸 코드로 변환하기
  • 메모리에 직접 작성되므로 어셈 코드를 쓸 순 없고 바이트로 변환된 형태를 사용해야 함
  • 이를 확인하려면 VS에서 디스어셈블리로 짜서 확인하거나 디버거로 확인하는 방법이 있다.
DWORD someFunction(int times, const char* string)
{
	__asm {
		push dword ptr[esp+0x4]
		push 0
		mov eax, 0x0
		call eax
		add esp, 0x8
		ret
	}
}

VS에서 인라인어셈을 활용한 디스어셈블리 실습

  • push 명령어의 경우 피연산자가 1바이트면 바이트코드로 6A로 표시되는데 2바이트 이상이면 68

	BYTE codeCave[20] = {
		0xFF, 0x74, 0x24, 0x04,	      // PUSH DWORD PTR[ESP+0x4]
		0x68, 0x00, 0x00, 0x00, 0x00, // PUSH 0x0
		0xB8, 0x00, 0x00, 0x00, 0x00, // MOV EAX, 0x00
		0xFF, 0xD0, 		     // CALL EAX
		0x83, 0XC4, 0x08,	     // ADD ESP, 0x08
		0xC3 			     // RETN
	};
  • 00 00 00 00 으로 표기된 부분은 times와 someFunction의 주소임
  • someFunction은 컴파일되기 전까지 함수의 주소를 알 수 없다.
  • 그래서 아래 코드를 추가해서 코드케이브에 값을 주입함
memcpy(&codeCave[5], &times, 4);
memcpy(&codeCave[10], &addressOfSomeFunc, 4);
  • times와 someFunction의 주소 모두 4바이트이므로 이렇게 처리할 수 있다.

 

 

 

메모리에 코드 케이브 작성하기
  • VirtualAllocEx와 WriteProcessMemory를 통해 코드케이브를 프로세스 메모리에 주입할 수 있다.
DWORD stringLen = strlen(string) + 1;
DWORD caveLen = sizeof(codeCave);

LPVOID strAddr = VirtualAllocEx(
	handle, 0, stringLen + caveLen, MEM_COMMIT, PAGE_EXECUTE
);

LPVOID caveAddr = (LPVOID)((DWORD)strAddr + stringLen);
WriteProcessMemory(handle, strAddr, string, stringLen, NULL);
WriteProcessMemory(handle, caveAddr, codeCave, caveLen, NULL);
  • stringLen과 코드 케이브의 크기를 대해서 타깃 프로세스의 메모리를 할당 받는다.
  • stringLen을 구함으로써 메모리에서 코드 케이브가 쓰여질 위치를 구할 수 있다.
  • strAddr에 string을 적어넣고, caveAddr에 코드 케이브를 적어 넣는다.

 

스레드 인젝션을 활용해 코드케이브 수행

 

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

DWORD printStringManyTimes(int times, const char* string)
{
	for (int i = 0; i < times; i++)
		printf(string);
	return 0;
}

void InjectMyProcess(HANDLE handle, LPVOID func, int times, const char* string)
{
	BYTE codeCave[20] = {
		0xFF, 0x74, 0x24, 0x04, // PUSH DWORD PTR[ESP+0x4]
		0x68, 0x00, 0x00, 0x00, 0x00, // PUSH 0x0
		0xB8, 0x00, 0x00, 0x00, 0x00, // MOV EAX, 0x00
		0xFF, 0xD0, // CALL EAX
		0x83, 0XC4, 0x08, // ADD ESP, 0x08
		0xC3 // RETN
	};

	memcpy(&codeCave[5], &times, 4);
	memcpy(&codeCave[10], &func, 4);

	DWORD stringLen = strlen(string) + 1;
	DWORD caveLen = sizeof(codeCave);

	LPVOID strAddr = VirtualAllocEx(
		handle, 0, stringLen + caveLen, MEM_COMMIT, PAGE_EXECUTE
	);

	LPVOID caveAddr = (LPVOID)((DWORD)strAddr + stringLen);
	WriteProcessMemory(handle, strAddr, string, stringLen, NULL);
	WriteProcessMemory(handle, caveAddr, codeCave, caveLen, NULL);

	HANDLE thread = CreateRemoteThread(
		handle, 0, 0, (LPTHREAD_START_ROUTINE)caveAddr, strAddr, 0, 0
	);

	WaitForSingleObject(thread, INFINITE);
	CloseHandle(handle);
}

int main()
{
	HANDLE handle = OpenProcess(
		PROCESS_ALL_ACCESS,
		false,
		GetCurrentProcessId()
	);

	InjectMyProcess(handle, &printStringManyTimes, 2, "Injected!\n");

	while (1)
	{
		Sleep(1);
	}
}
  • 실행할 스레드의 Entry Point로 주입했던 caveAddr을 넘겨주는 것을 볼 수 있다.
  • 그리고 그 스레드의 인자로 주입했던 strAddr을 넘겨준다.

 

 


코드 케이브를 실행하기 위해 메인 스레드 하이재킹하기
  • 코드 케이브와 메인 스레드가 싱크돼야 한다면? 쉬운 일이 아님
  • 외부 프로세스 상의 스레드를 제어할 수 있어야 하기 때문
  • 코드 케이브가 끝날 때까지 메인 스레드의 실행을 지연시키는 것도 방법이긴 한데
  • 기다리는 동안 오버헤드가 가중되고 다시 움직일 때 처리해야할 양이 많아서 매우 느려질 수 있음
  • 대안은 EIP를 변경하여 스레드로 하여금 코드를 실행시키는 것
  • 이 과정을 스레드 하이재킹이라고 한다.

 

어셈블리 코드 케이브 만들기
  • 코드 케이브가 실행될 때 스레드 상태를 저장하고, 하이재킹이 끝나면 다시 불러올 필요가 있음
  • ---> 셸코드가 어셈블리코드로 래핑돼야 한다는 뜻
PUSHAD // 범용 레지스터를 스택에 넣음
PUSHFD // EFLAGS를 스택에 넣음

//셸코드 위치

POPFD // 스택에서 EFLAGS를 불러옴
POPAD // 스택에서 범용 레지스터를 불러옴
  • 이전과 동일하게 someFunction을 호출한다면
  • 이번 예제에선 CreateRemoteThread를 사용하지 않기 때문에 
  • 두 번째 파라미터도 첫 번째 파라미터처럼 전달해주어야 한다는게 이전과의 차이점
PUSH string
PUSH times
MOV EAX, someFunction
CALL EAX
ADD ESP, 0x8
  • someFunction을 호출하는 어셈블리
  • 이전과는 달리 string도 push로 넘겨주는 게 보인다
  • 그리고 retn이 없다.

 

  • 스레드를 정상적으로 다시 수행하기 위해선 retn없이 원래 eip로 점프해야 한다 (esp, ebp를 건들 ㄴㄴ)
  • 이전 eip는 GetThreadContext로 가져올 수 있다.
  • 그러면 대략 이런 모양으로 코드 케이브가 만들어짐

 

PUSHAD
PUSHFD

PUSH string(0x00) // 마지막 인자부터
PUSH times(0x00)
MOV EAX, someFunction(0x00)
CALL EAX
ADD ESP, 0x8

POPFD
POPAD

PUSH ORG_EIP(0x00)
RETN
  • 위 변수 중 times와 someFunction은 바로 확인이 가능하지만 string과 eip는 실행되고 나서 알 수 있다.

 

BYTE codeCave[31] = {
	0x60, // PUSHAD
	0x9C, // PUSHFD
	0x68, 0x00, 0x00, 0x00, 0x00, // PUSH string
	0x68, 0x00, 0x00, 0x00, 0x00, // PUSH times
	0xB8, 0x00, 0x00, 0x00, 0x00, // MOV EAX, someFunction
	0xFF, 0xD0, // CALL EAX
	0x83, 0xC4, 0x08, // ADD ESP, 0x8
	0x9D, // POPFD
	0x61, // POPAD
	0x68, 0x00, 0x00, 0x00, 0x00, // PUSH ORG_EIP
	0xC3  // RETN
};

 

 

 

 

메인 스레드 찾고 멈추기
  • 메인 스레드를 멈추려면 스레드 고유 식별자를 알아야 함 - pid를 얻는 작업과 유사함
  • 게임 프로세스가 생성한 첫 번째 스레드를 가져오는 코드
DWORD GetProcessThreadID(HANDLE hProcess)
{
	HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, NULL);
	THREADENTRY32 entry;
	entry.dwSize = sizeof(entry);
	DWORD ret = 0;
	if (Thread32First(hSnap, &entry))
	{
		DWORD pid = GetProcessId(hProcess);
		while (Thread32Next(hSnap, &entry))
		{
			if (entry.th32OwnerProcessID == pid)
			{
				ret = entry.th32ThreadID;
				break;
			}
		}
	}

	CloseHandle(hSnap);
	return ret;
}
  • 가져온 threadID를 가지고 스레드에 대한 핸들을 가져온 후 스레드를 멈춘다
  • 멈춘 후 스레드 컨텍스트를 가져와서 저장하고 EIP를 우리가 WriteProcessMemory로 주입한 코드로 조작한다.
  • 그리고 다시 SetThreadContext를 통해 세팅한 후 멈춘 스레드를 다시 실행시킨다.

 

 

최종 코드
void threadHijack(HANDLE handle, LPVOID func, int times, const char* string)
{
	BYTE codeCave[31] = {
		0x60, // PUSHAD
		0x9C, // PUSHFD
		0x68, 0x00, 0x00, 0x00, 0x00, // PUSH string
		0x68, 0x00, 0x00, 0x00, 0x00, // PUSH times
		0xB8, 0x00, 0x00, 0x00, 0x00, // MOV EAX, someFunction
		0xFF, 0xD0, // CALL EAX
		0x83, 0xC4, 0x08, // ADD ESP, 0x8
		0x9D, // POPFD
		0x61, // POPAD
		0x68, 0x00, 0x00, 0x00, 0x00, // PUSH ORG_EIP
		0xC3  // RETN
	};

	int stringLen = strlen(string) + 1;
	int caveLen = sizeof(codeCave);
	int fullLen = stringLen + caveLen;

	auto remoteString = VirtualAllocEx(handle, 0, fullLen, MEM_COMMIT, PAGE_EXECUTE);
	auto remoteCave = (LPVOID)((DWORD)remoteString + stringLen);
	// 할당 코드는 이전 예제와 동일

	DWORD tid = GetProcessThreadID(handle);
	HANDLE thread = OpenThread(
		THREAD_GET_CONTEXT | THREAD_SUSPEND_RESUME | THREAD_SET_CONTEXT,
		false, tid
	);

	SuspendThread(thread); // 스레드 지연
	CONTEXT threadContext;
	threadContext.ContextFlags = CONTEXT_CONTROL; // 검색할 값에 대한 플래그 세팅
	GetThreadContext(thread, &threadContext); 

	memcpy(&codeCave[3], &remoteString, 4);
	memcpy(&codeCave[8], &times, 4);
	memcpy(&codeCave[13], &func, 4);
	memcpy(&codeCave[25], &threadContext.Eip, 4);

	WriteProcessMemory(handle, remoteString, string, stringLen, NULL);
	WriteProcessMemory(handle, remoteCave, codeCave, caveLen, NULL);

	threadContext.Eip = (DWORD)remoteCave;
	threadContext.ContextFlags = CONTEXT_CONTROL;
	SetThreadContext(thread, &threadContext);
	ResumeThread(thread);

	CloseHandle(thread);
}

 

 


DLL 인젝션
  • 어셈블리어로 만든 코드케이브는 강력하지만 효율적이지 않음
  • C++ 코드로 그냥 사용할 수 있게 만들면 안될까?
  • 이러려면 어셈블리로 컴파일되어야 함
  • 사용하는 함수들의 의존성을 사전에 인지하고 메모리 매핑을 정확히 해야 함
  • 이 모든 것이 윈도우에서 DLL을 통해 가능하다.
  • 방법은 스레드 하이재킹 혹은 LoadLibrary 주입

 

DLL 로딩을 위해 프로세스 속이기
// TestHack.cpp

int main()
{
	HWND hwnd = FindWindow(L"notepad", 0);
	if (!hwnd)
	{
		return 0;
	}

	DWORD pid;
	GetWindowThreadProcessId(hwnd, &pid);
	HANDLE process = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
	const wchar_t* dllName = L"C:\\MakeDLL.dll";
	int nameLen = wcslen(dllName) + 1;

	LPVOID remoteString = VirtualAllocEx(process, NULL, nameLen *2, MEM_COMMIT, PAGE_EXECUTE);
	WriteProcessMemory(process, remoteString, dllName, nameLen*2, NULL);

	HMODULE kernel32 = GetModuleHandleA("kernel32.dll");
	LPVOID funcAdr = GetProcAddress(kernel32, "LoadLibraryW");

	if (funcAdr)
	{
		std::cout << funcAdr << std::endl;
	}

	HANDLE thread = CreateRemoteThread(process, 0, 0, (LPTHREAD_START_ROUTINE)funcAdr, remoteString, 0, 0);
	WaitForSingleObject(thread, INFINITE);

	std::cout << "ok" << std::endl;

	CloseHandle(process);
}
// MakeDLL.cpp

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

BOOL APIENTRY DllMain(HINSTANCE InstDll, DWORD dwReason, LPVOID lpvReserved)
{
	
	switch (dwReason)
	{
	case DLL_PROCESS_ATTACH:
		MessageBoxA(0, "Dll Attach!", "Inject", MB_OK);
		break;


	case DLL_PROCESS_DETACH:
		MessageBoxA(0, "Dll Detach!", "eject", MB_OK);
		break;
	}

	return TRUE;
}

  • LoadLibrary는 하나의 인자만 가지기 때문에 CreateRemoteThread를 사용하기 용이함
  • LoadLibrary가 불리고 MakeDLL이 가상메모리에 매핑되면 DLL_PROCESS_ATTACH가 불림
  • 메모장이 종료되면 DLL_PROCESS_DETACH가 불림

 

로더락이란?

프로세스 상 로드된 모듈의 리스트를 읽거나 수정할 때 사용하는 락
  • DLL 엔트리 포인터는 로더락 안에서 실행됨
  • DLL Main에서 중요한 코드를 실행하기보단 스레드로 빼서 동작하게 하는게 올바르다.
DWORD WINAPI runBot(LPVOID lpParam)
{
	return 1;
}

//DLL_PROCESS_ATTACH
auto thread = CreateThread(NULL, 0, &runBot, NULL, 0, NULL);
CloseHandle(thread);
  • 스레드를 실행하고 바로 CloseHandle을 한 이유는
  • 스레드 핸들로 뭔가를 할 껀덕지도 없거니와 커널 오브젝트의 레퍼런스 카운트가 총 2개이므로
  • 스레드 핸들을 닫아도 프로세스가 실행되는 동안 일을 잘 끝마치고 종료될 것이기 때문
  • 작업하려는 DLL 내부에서 의존성이 있는 함수들을 사용할 경우, 의도대로 실행되지 않을 수 있음
  • 가장 좋은 방법은 모든 외부 라이브러리를 정적으로 링크를 걸어 DLL에서 컴파일되게 하는 것

 

 

 

주입된 DLL 안에서 메모리 접근하기

 

  • DLL은 해당 실행 프로그램 내부에서 같은 메모리 공간을 공유하므로
  • DLL에서 실행 프로그램의 메모리에 접근할 수 있다.
  • 대신 타입 캐스팅을 해야 하는데 여간 귀찮은게 아니기 때문에 아래처럼 일반화시키는게 좋다.
template<typename T>
T readMemory(LPVOID adr)
{
	return *((T*)adr);
}

template<typename T>
void writeMemory(LPVOID adr, T val)
{
	*((T*)adr) = val;
}
  • 이전에 배웠던 writeMemory, readMemory 일반화와 비슷함
  • 그런데 이렇게 쓰면 더 유연하게 사용 가능하다.
template<typename T>
T* pointMemory(LPVOID adr)
{
	return ((T*)adr);
}

 

 

 

 

DLL을 통해 ASLR 우회하기
  • DLL이 실행 파일과 같은 메모리에 위치하기 때문에 GetModuleHandle을 통해 기본 주소를 바로 가져올 수 있다.
  • 더 쉬운 방법은 PEB를 가져오는 방법인데 FS 세그먼트 레지스터는 TEB를 가리킴
  • FS에서 0x30위치에 PEB가 위치하는데 PEB의 0x8 위치에 메인 모듈의 기본 주소가 있다.
  • 따라서 아래처럼 처리가 가능하다.
DWORD newBase;
__asm {
	MOV EAX, DWORD PTR FS:[0x30]
	MOV EAX, DWORD PTR DS:[EAX+0x08]
	MOV newBase, EAX 
}

 

댓글