X-Operator 월핵만들기
2022년 4월 1일
ref #
https://blog.theori.io/game-hacking-1-881e940ffe00
https://iforint.tistory.com/50
https://coinz.tistory.com/142?category=95023
https://my-repo.tistory.com/67
https://hack.kr/316
https://hackbox.tistory.com/16
핵 종류를 알아보자 #
| 종류 | 장점 | 단점 |
|---|---|---|
| 클라이언트 파일 조작 | 한 번 변조하고 나면 계속해서 적용된다 | 보호 프로그램의 무결성 검사나 신규 버전이 배포되면 새로 만들어야 한다 |
| 네트워크 패킷 변조 | 클라이언트 프로그램과 따로 동작하기 때문에 가장 탐지하기 어려운 핵이다 | 프록시 역할을 하는 프로그램에서 모든 패킷을 검사해서 조작하기 때문에 과부하가 걸릴 수 있고, 패킷이 암호화되어 있는 경우도 있어서 어렵다 |
| 메모리 데이터 변조 | 원하는 메모리 위치만 찾게 된다면 조작하기 쉽기 때문에 가장 많이 사용된다 | 메모리를 읽고 쓰는 패턴이기 때문에 안티치트 프로그램에 의해 미리 함수가 후킹돼서 탐지할 수 있다 |
월핵의 원리 #
도형 3개가 그려진 상황을 생각해보자
화면에서 도형이 표시될때 당연하게도 가장 앞에있는 S3 도형이 모든 도형을 가리게 된다.
컴퓨터 그래픽 렌더링을 할때는 Z Buffer를 사용해서 Z(깊이) 값에 따라 도형이 다른 도형을 덮어씌울지 말지 정해진다.
예시 그림에서 오른쪽이 Z Buffer인데, Z값이 10인 사각형(s2)이 먼저 그려지고 Z값이 5인 원(s1)이 그려지는데 모든 공간에 그려지는게 아니라 Z값이 자신보다 낮은 공간(0)에만 덮어씌워진것을 확인할 수 있다.
마지막으로 삼각형(s3)이 그려질땐 Z Buffer의 모든 공간이 자신의 Z값보다 낮기 때문에 자신의 존재감을 그대로 드러낼 수 있게된다.
월핵이라는것은 결국 벽 건너편의 사람을 보이게 하는것이고 보통 게임을 렌더링할땐 벽을 먼저 그리고 사람은 그 후에 처리하게될것이다. 그렇기때문에 Z Buffer의 기능을 꺼둔다면 사람이 어디에있는지 볼 수 있게된다.
월핵 만들기 #
미니서든프로그램을 기드라로 열어보면 D3D8 을 임포트 하고있다.
1. 악의적인 코드를 게임 프로세스에 집어넣기 #
일단 게임 프로세스의 메모리에 악의적인 행동(메모리 값 변조, API Hooking 등)을 하기 위해서는 디버거이거나, 같은 프로세스 내에 코드가 있어야 한다.
윈도우에서는 디버깅 API를 제공해주는데, WriteProcessMemory()와 같은 API들이다. 디버깅 API는 디버기 프로세스의 메모리 접근 권한 즉 관리자 권한을 갖게되면 사용할 수 있는데, 악성코드가 아닌 핵을 만드는 사람 입장에서 Injector를 임의로 관리자 권한으로 실행시키는 것은 어렵지않다.
https://blog.naver.com/sheepst3/222689420105
2. 후킹할 함수 주소 찾기 #
d3d의 후킹을 진행할때는 렌더를 종료할때 사용되는 IDirect3DDevice8::EndScene 함수를 주로 후킹한다.
vtable은 동적으로 함수를 바인딩할 때 사용되는 객체별로 존재하는 가상함수 테이블이기 때문에 vtable의 주소를 알아낸다면 오프셋계산으로 객체의 모든 가상함수들의 주소를 알아낼 수 있다.
분석할때 어떤 방식으로 라이브러리를 사용하는지 알면 편해지기 때문에 이 문서를 참고했다.
https://drunkenhyena.com/pages/projects/d3d8/d3d_lesson1.php
Diret3DCreate8() 의 리턴값으로 Direct3D8 객체가 반환되며, 이 객체의 CreateDevice() 함수로 IDirect3DDevice8 인터페이스가 생성되기 때문에 CreateDevice() 를 분석해서 vtable의 주소를 찾아야한다.
2-1. CreateDevice() 호출 코드 찾기 #
미니서든의 entry 코드에서 WinMain(hInstanceExe, 0, pszCmdLine, ncmdShow) 함수를 만날 수 있다.
계속 분석하다보면 Direct3DCreate8() 을 호출하는 부분을 볼 수 있는데 이 코드가 d3d8 객체를 초기화해주는 코드이고, 위의 d3d8 예제 코드와 비교해보면 아래쪽에 this 포인터를 제외하고 6개의 인자를 넘기는 부분이 있는데 이부분이 CreateDevice() 호출코드인 것을 알 수 있다.
2-2. CreateDevice() 에서 IDirect3DDevice8 의 vftable 초기화 코드 찾기 #
결국 CreateDevice 코드는 d3d8.dll에 있기 때문에 d3d8.dll 파일로 넘어가서 분석을 시작하자.
기드라에서 pdb(Program Database) 파일 로드해서 심볼을 복원하고 분석하면 편하다.
File → Load PDB File… → config 에서 심볼저장소들을 추가하고 advanced → search all 로 pdb파일을 다운받은 뒤 자동분석 기능을 이용하면 심볼이 복원된 것을 확인할 수 있다.
심볼트리에서 CreateDevice를 검색하면 메소드를 확인할 수 있고 함수 원형을 보면 6번째 인자로 전달되는 더블 포인터에 IDirect3DDevice8 인터페이스 포인터가 리턴되는것을 확인할 수 있다.
1HRESULT CreateDevice(
2 UINT Adapter,
3 D3DDEVTYPE DeviceType,
4 HWND hFocusWindow,
5 DWORD BehaviorFlags,
6 D3DPRESENT_PARAMETERS* pPresentationParameters,
7 IDirect3DDevice8** ppReturnedDeviceInterface
8);
CreateDevice 내부에서 인터페이스 포인터의 인자를 따라가보면, CD3DHal() 과 연결되어 있는것을 확인할 수 있다. 이 메소드가 vftable을 세팅하게된다.
2-3. EndScene() 함수의 인덱스 찾기 #
vtable은 미리 정의된 순서에 따라 함수가 저장되는데, 이 함수의 인덱스는 헤더파일을 확인해보면 된다.
36번째 함수로 define되어 있기 때문에 36번 인덱스임을 확인할 수 있다.
기드라에서는 이미 심볼이 복구되어있기 때문에 vftable을 따라들어가서 찾다보면 EndScene()을 찾을 수 있다.
이것으로 vftable의 주소만 찾으면 인덱스에 접근해서 타겟인 EndScene() 함수의 주소를 찾을 수 있게 되었다.
2-4. 프로그램 동작중에 vftable의 주소 찾기 #
vftable을 채우는 기계어코드 패턴을 이용해서, c7 06 20 a0 6f 1e 89 86 28 05 00 00 에서 밑줄친 부분은 고정 값이고, 나머지 부분은 주소값에 해당한다.
1bool bCompare(const BYTE* pData, const BYTE* bMask, const char* szMask)
2{
3 for(;*szMask; ++szMask, ++pData, ++bMask)
4 if(*szMask == 'x' && *pData != *bMask)
5 return false;
6 return (*szMask) == NULL;
7}
8
9DWORD FindPattern(DWORD dwAddress, DWORD dwLen, BYTE *bMask, char *szMask)
10{
11 for(DWORD i = 0; i < dwLen; i++)
12 if( bCompare( (BYTE*)(dwAddress + i), bMask, szMask) )
13 return (DWORD)(dwAddress + i);
14 return 0;
15}
16
17DWORD hModule = (DWORD)GetModuleHandle(L"d3d8.dll");
18DWORD table = FindPattern((DWORD)hModule, 0x128000, // 라이브러리의 크기. 버전마다 다르다
19 (PBYTE)"\xC7\x06\x00\x00\x00\x00\x89\x86\x00\x00\x00\x00\x89\x86",
20 "xx????xx????xx" // 가변적인 주소 부분은 ?로 마스킹
21);
22memcpy(&vTable, (void*)(table+2), 4); // 2byte는 고정명령어. table은 code영역 주소이다.
GetModuleHandle의 리턴값은 핸들이라고 불리지만, ImageBase주소와 같은 값을 갖는다. d3d8.dll 라이브러리의 ImageBase 주소부터 전체 크기만큼 모든 메모리를 검사해서 vftable 초기화 패턴이 발견되면 주소를 vTable에 저장해서 이후에는 인덱스로 함수 주소에 접근하는 방식을 사용하면 될것이다.
※vftable이나 함수나 ImageBase + 오프셋 이기 때문에 DLL의 컴파일 버전이 같다면 같은 offset을 사용해도 된다. 하지만 배포하기 위해서라면 패턴매칭을 해야한다.
3. 함수에 Inline Hook을 설치해서 월핵 동작시키기 #
드디어 위의 과정을 거쳐서 목표했던 후킹을 할 수 있게 되었다.
도형을 그릴땐 DrawIndexedPrimitive() 함수를 사용하기 때문에 예시는 DIP 기준으로 그려져있다.
그림을 그리기 전에 Z Buffer를 비활성화 하고, DIP를 호출해 그리고 나서 활성화 시켜준다. 활성화 시켜주는 이유는 나중에 다시 후킹됐을때 비활성화 상태에서 다시 비활성화 코드를 호출하지 않기 위해서이다.
후킹방법은 아래를 참고할 수 있다.
https://blog.naver.com/sheepst3/222690306764
가짜 함수에서는 ZBuffer 기능을 끈 후 전달받은 인자는 그대로 실제 함수로 넘기고 그림을 그린다음 다시 활성화 시키는게 동작의 전부이다.
1typedef HRESULT (__stdcall *tEndScene)(LPDIRECT3DDEVICE8);
2typedef HRESULT(__stdcall* tDrawIndexedPrimitive)(
3 LPDIRECT3DDEVICE8 pDevice,
4 D3DPRIMITIVETYPE pType,
5 UINT nMinIndex,
6 UINT nNumVertices,
7 UINT nStartIndex,
8 UINT nPrimitiveCount
9);
10
11void __stdcall hkDrawIndexedPrimitive(LPDIRECT3DDEVICE8 pDevice, D3DPRIMITIVETYPE pType, INT BaseVertexIndex, UINT MinVertexIndex, UINT NumVertices, UINT startIndex, UINT primCount)
12{
13 pDevice->SetRenderState(D3DRS_ZENABLE, D3DZB_FALSE); // Z Buffer 비활성화
14 // Drawing 전
15 oDrawIndexedPrimitive(pDevice, pType, BaseVertexIndex, MinVertexIndex, NumVertices, startIndex, primCount);
16 // Drawing 후
17 pDevice->SetRenderState(D3DRS_ZENABLE, D3DZB_TRUE); //Z Buffer 활성화
18}
※프로그램은 자신이 원본 API를 호출했다고 생각하고 호출 전 거기에 맞는 스택을 만들어줬기 때문에, 원본 API와 가짜 API의 호출 규약을 맞춰줘야한다. DIP같은 경우에는 __stdcall 이나 WINAPI를 선언해줘야 한다.
이렇게 설치만 한다면 Z Buffer를 무시하고 볼 순 있지만, 총알은 아직도 가장 앞에있는 물체에 박히기 때문에 적 캐릭터가 보인다 해도 실제로 맞출 수 있는 상황인지 파악할 수 없어서 반쪽짜리 핵이 된다.
EndScene, DrawIndexedPrimitive 함수 전부 인자가 검색했을때 나오는 함수 원형 보다 1개 많은것을 확인할 수 있다. C++에서 this 포인터의 인자를 첫번째인자로 넘겨주기 때문에 맨앞은 this의 자리이다.
후기 #
완성하면 이렇게 벽뒤의 적들을 볼 수 있다
d3d8 기준으로 해봤는데, 다행히 기드라에 pdb를 올려서 심볼을 확인할 수 있었고 CreateDeviceImpl 같은 함수는 사용하지 않았지만 전체적인 로직은 d3d9와 방식이 비슷해서 만들기 어렵지 않았다.
후킹함수 소스코드를 이해하고 직접 생각하면서 만들었는데 완성하고나서 계속 게임이 종료되는 상황이 발생했다.
ollydbg로 하나하나 따라가면서 변수에 주소값을 넣을때 DWORD 형변환이 되어있지 않아서 에러가 발생했고 점프 계산식이 5byte정도 잘못된 부분도 있었다.