본 게시글은 메모리에 대한 깊은 이해를 위해 에이콘 출판사의 "봇을 이용한 게임 해킹" 서적을 보고 필기한 자료입니다.
따라서 디테일한 부분에서 본 서적의 실제 내용과 다를 수 있고 글쓴이의 주관이 들어가 있음을 참고해주시기 바랍니다.
문제 시 비공개 처리하도록 하겠습니다.
코드 인젝션이란 프로세스가 수행되는 메모리 공간과 실행 환경에서 외부 코드를 수행하도록 강제하는 것
코드 인젝션 방법
- 코드 케이브를 만들고 스레드를 주입하는 방법
- 직접 외부 바이너리를 이식하는 것
스레드 인젝션으로 코드 케이브 주입하기
- 다른 프로세스에 코드를 주입하는 첫 단계는 셸 코드
- 셸 코드란? 위치와 상관없이 수행 가능한 어셈블리 코드를 바이트 배열 형태로 작성한 것
- 셸 코드를 작성해 코드 케이브를 만들 수 있고, 코드 케이브는 새 스레드의 시작주소 역할
- 그 후 스레드 인젝션 혹은 스레드 하이재킹으로 실행-
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
}
}
- 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], ×, 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], ×, 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], ×, 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
}
'게임 보안 > [서적] 봇을 이용한 게임 해킹' 카테고리의 다른 글
7. 게임 플로우 조작하기 (1) | 2022.10.04 |
---|---|
함수 포인터가 함수를 직접 가리키지 않는다!? (0) | 2022.10.02 |
5. 게임 메모리 읽고 쓰기 (1) | 2022.09.29 |
4. 고급 메모리 포렌식 (0) | 2022.09.28 |
3. x86 어셈블리 크래시 코스 (1) | 2022.09.28 |
댓글