헛둘이 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);