D3D 후킹 한번 해보려다 온갖걸 다 해보고 다시 돌아왔다.
다음과 같은 순서로 D3D 후킹이 진행된다.
- 코드 패턴찾아 Vtable로부터 EndScene 함수 주소 찾아오기
- Detour 함수 이용해 코드 패치
- trampoline으로 점프하여 후킹함수 실행
- 후킹함수 실행하여 원하는 동작 수행
- Trampoline - 원본함수로 돌아가 EndScene 함수 마저 실행됨
EndScene의 Vtable 주소 가져오기
이전 문서에서 찾은 헥스코드 패턴을 이용해 vtable 주소값을 얻어올 것이다.
C7 06 ?? ?? ?? ??
89 86 ?? ?? ?? ??
89 86 ?? ?? ?? ??
DWORD FindPattern(DWORD dwAddress,DWORD dwLen,BYTE *bMask,char * szMask)
{
for(DWORD i=0; i < dwLen; i++){
if( bDataCompare( (BYTE*)( dwAddress+i ),bMask,szMask) )
return (DWORD)(dwAddress+i);
}
return 0;
}
bool bDataCompare(const BYTE* pData, const BYTE* bMask, const char* szMask)
{
for(;*szMask;++szMask,++pData,++bMask){
if(*szMask=='x' && *pData!=*bMask )
return false
}
return (*szMask) == NULL;
}
위 코드는 메모리 영역을 지정 후 비트단위 비교 연산을 진행하여 조건과 일치하는 부분이 있다면 해당 메모리 영역의 주소값을 반환하는 코드이다. 하나씩 차근차근 뜯어보자.
DWORD FindPattern(DWORD dwAddress,DWORD dwLen,BYTE *bMask,char * szMask)
{
for(DWORD i=0; i < dwLen; i++){
if( bDataCompare( (BYTE*)( dwAddress+i ),bMask,szMask) )
return (DWORD)(dwAddress+i);
}
return 0;
}
dwAddress : 검색 시작 주소
dwLen : 검색할 메모리 범위
bMask : 검색할 바이트 패턴
szMask : 패턴 마스크
검색 시작주소와 메모리 범위가 지정되면 해당 메모리의 시작지점부터 bDataCompare 함수가 실행되며 바이트 패턴 + 마스크를 이용한 데이터 비교가 이뤄진다.
지정한 메모리 범위만큼 반복문이 돌면서 탐색을 진행하다 bDataCompare 함수의 반환값이 true가 되면 해당 메모리 지점의 주소를 반환하고 탐색을 종료한다.
bool bDataCompare(const BYTE* pData, const BYTE* bMask, const char* szMask)
{
for(;*szMask;++szMask,++pData,++bMask){
if(*szMask=='x' && *pData!=*bMask )
return false
}
return (*szMask) == NULL;
}
pData : 탐색할 메모리 주소
bMask : 검색할 바이트 패턴
szMask : 패턴 마스크
인자로 넘어온 메모리 주소에서 시작하여 szMask 포인터가 1씩 커지다 값을 가지지 않게 되면 반복문이 종료된다. 즉, 패턴 마스크 문자열의 길이만큼 반복문이 실행된다.
반복문 내부에서는 패턴 마스크가 x일 때 지정 메모리 영역의 값과 바이트 패턴이 일치하는지 비교하고, 일치한다면 다음 바이트를 비교하기 위해 메모리 주소와 바이트패턴, 패턴마스크 포인터를 1씩 더해준다.
그렇지 않으면 현재 진행중인 함수를 종료하고 false를 반환, FindPattern 함수의 if 분기가 실행되지 않아 다음 반복문으로 넘어가게끔 한다.
패턴 마스크가 x 가 아니라면 비교를 진행하지 않고 포인터만 늘려주기때문에, x가 아닌 부분엔 어떤 값이 들어가도 상관하지 않는다고 보면 되겠다.
DLL injection을 진행하여 위 코드가 실행되었을때 메모리의 주소가 잘 받아와지는지 확인해보자.
int StartD3DHooks()
{
DWORD vftable_addr, DXBase = NULL;
FILE* pFile = nullptr;
DXBase = (DWORD)LoadLibraryA("d3d9.dll");
while (!DXBase);
{
vftable_addr = FindPattern(DXBase, 0x128000,
(PBYTE)"\xC7\x06\x00\x00\x00\x00\x89\x86\x00\x00\x00\x00\x89\x86\x00\x00\x00\x00", (char *)"xx????xx????xx????");
}
if (AllocConsole()) {
freopen_s(&pFile, "CONIN$", "rb", stdin);
freopen_s(&pFile, "CONOUT$", "wb", stdout);
freopen_s(&pFile, "CONOUT$", "wb", stderr);
printf("vtable addr : %08x", vftable_addr); }
return 0;
}
받아온 주소는 62627c9b
오, 제대로 찾아왔다. 이제 저 코드영역에 있는 Vtable 주소 4바이트를 받아오고, 포인터가 가리키는 메모리 주소 + 42번째 위치에 있는 EndScene 함수의 주소를 받아오면 되겠다.
후킹 함수에 한가지 기능을 더 추가해야되는데, 원본 함수에서 덮어씌운 바이트 코드의 내용 일부를 가져와 트램펄린 함수에서 쓰는 기능이 필요할 것 같다.
void Tramp() {
__asm {
PUSHFD //save all register and flags
PUSHAD
CALL hookf //call custom function
POPAD
POPFD
PUSH EBP //origin function's asm
MOV EBP,ESP
SUB ESP,8
JMP [origin_addr] //back to origin
}
}
기존 트램펄린 함수는 위와 같이 구성된다. POPFD 까지는 고정적인 동작을 가지지만, 그 이후 SUB ESP,8 까지는 원본 함수의 어셈블리 코드를 가져온 것이다.
그러나 이번같은 경우엔 덮어씌울 위치에 메모리의 주소값이 하드코딩으로 박혀있기 때문에, 다른 방식을 사용해야 할듯싶다.
동적할당을 이용해 받아온 코드 내용을 활용하는 식으로 프로그램을 짠게 보였다.
void* DetourFunction64(void* pSource, void* pDestination, int dwLen)
{
DWORD MinLen = 14;
if (dwLen < MinLen) return NULL;
BYTE stub[] = {
0xFF, 0x25, 0x00, 0x00, 0x00, 0x00, // jmp qword ptr [$+6]
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 // ptr
};
void* pTrampoline = VirtualAlloc(0, dwLen + sizeof(stub), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
DWORD dwOld = 0;
VirtualProtect(pSource, dwLen, PAGE_EXECUTE_READWRITE, &dwOld);
DWORD64 retto = (DWORD64)pSource + dwLen;
// trampoline
memcpy(stub + 6, &retto, 8);
memcpy((void*)((DWORD_PTR)pTrampoline), pSource, dwLen);
memcpy((void*)((DWORD_PTR)pTrampoline + dwLen), stub, sizeof(stub));
// orig
memcpy(stub + 6, &pDestination, 8);
memcpy(pSource, stub, sizeof(stub));
for (int i = MinLen; i < dwLen; i++)
{
*(BYTE*)((DWORD_PTR)pSource + i) = 0x90;
}
VirtualProtect(pSource, dwLen, dwOld, &dwOld);
return (void*)((DWORD_PTR)pTrampoline);
}
임시 stub 코드 바이트배열 길이만큼 동적할당을 하고, 할당한 메모리 공간에 바이트코드를 복사하는 식으로 진행된다. 그 다음 detour 함수가 종료될 떄 트램폴린 함수로 넘어가는 식으로 동작을 구성했다.
이렇게 하면 별도의 트램펄린 함수를 만들 필요도 없고, 길이만 지정하면 자동으로 바이트코드를 받아와 트램펄린을 구성해줄 수 있다. 내 상황에 맞춰 쓸 수 있게끔 코드를 재구성해보자.
필요한 헥스코드를 나열해보면
0x9C 0x60 0xe8 0x?? 0x?? 0x?? 0x?? 0x61 0x9d 0x?? ~~ 0xff 0x25 0x?? 0x?? 0x?? 0x??
이런식이 될듯. Detour 코드 안에 Trampoline을 생성하는 구문과 각종 코드주소를 덮어씌우는 구문들을 추가하여 코드를 완성하였는데, 이렇게 하고 후킹을 진행하면 엑세스 위반 오류가 뜨면서 게임 클라이언트가 꺼지더라.
후킹되는 코드 시점을 변경해야할듯.
상대주소 관련한 코드가 있는 부분보단 차라리 313c 부근이 후킹하기에 더 편해보인다.
시작주소로부터 +0c 위치를 5바이트 만큼 코드패치하자.
//dllmain
int StartD3DHooks()
{
DWORD vftable_addr, DXBase,ES_addr = NULL;
FILE* pFile = nullptr;
DXBase = (DWORD)LoadLibraryA("d3d9.dll");
while (!DXBase);
{
vftable_addr = hook::FindPattern(DXBase, 0x128000,
(PBYTE)"\xC7\x06\x00\x00\x00\x00\x89\x86\x00\x00\x00\x00\x89\x86\x00\x00\x00\x00", (char *)"xx????xx????xx????");
}
ES_addr = *((BYTE*)vftable_addr + 5)*0x1000000 + *((BYTE*)vftable_addr + 4)*0x10000 + *((BYTE*)vftable_addr + 3)*0x100 + *((BYTE*)vftable_addr + 2)*0x1;
ES_addr = (DWORD)((DWORD*)ES_addr + 42);
ES_addr = *(DWORD*)ES_addr + 0xc;
if (AllocConsole()) {
freopen_s(&pFile, "CONIN$", "rb", stdin);
freopen_s(&pFile, "CONOUT$", "wb", stdout);
freopen_s(&pFile, "CONOUT$", "wb", stderr);
printf("vtable code addr : %08x \n", vftable_addr);
printf("function addr : %08x \n", ES_addr);
}
hook::detour(ES_addr,5);
return 0;
}
//hook.h
void detour(DWORD origin, int size) {
DWORD protect = 0;
DWORD tmp_addr = 0;
VirtualProtect((LPVOID)origin, size, PAGE_EXECUTE_READWRITE, &protect);
return_addr = origin + size;
//make trampoline
//1. PUSHFD, PUSHAD, CALL hooker - stub1
//2. POPAD, POPDF, - stub1
//3. original byte codes - memcpy from origin code
//4. JMP original code - stub2
BYTE stub1[] = {
0x9C, 0x60, 0xe8, 0x00, 0x00, 0x00, 0x00, 0x61, 0x9d
};
//set byte code
BYTE stub2[] = {
0xE9, 0x00, 0x00, 0x00, 0x00
};
LPDWORD pTramp = (LPDWORD)VirtualAlloc(0, size + sizeof(stub1) + sizeof(stub2), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
tmp_addr = (DWORD)hook::hookf - (DWORD)pTramp - 7; //relative to tramp code
for (int i = 0; i != 4; i++) {
stub1[3+i] = (BYTE)tmp_addr;
tmp_addr = tmp_addr >> 8;
}
tmp_addr = return_addr - (DWORD)pTramp - 19; //relative to tramp code
for (int i = 0; i != 4; i++) {
stub2[1 + i] = (BYTE)tmp_addr;
tmp_addr = tmp_addr >> 8;
}
//set trampoline
if (pTramp) {
memcpy(pTramp, stub1, sizeof(stub1)); //copy ~ stub1
memcpy( (LPBYTE)pTramp + sizeof(stub1) , (LPVOID)origin, size); //copy origin code
memcpy( (LPBYTE)pTramp + sizeof(stub1) + size, stub2, sizeof(stub2)); //copy origin code
}
else
return;
//overwrite
DWORD newOffset = (DWORD)pTramp - origin - 5; //relative to origin code
printf(" tramp addr %08x \n", newOffset);
printf(" tramp addr %08x \n", pTramp);
*((LPBYTE)origin) = 0xE9;
origin += 1;
*((LPDWORD)origin) = newOffset;
origin += 4;
}
StatD3DHooks 함수와 detour 함수를 다시 작성하였다.
위와같이 코드패치 진행되어 다른 상대주소에 의한 간섭을 최대한 줄였다.
트램펄린 어셈블리는 이렇게 나온다.
EndScene 함수가 실행될 때 마다 hook 함수가 실행되는 모습을 볼 수 있다.
이제 후킹된 함수에 다른동작을 추가해보자.
DirectX의 동작 방식
DirectX를 이용하기 위해선 d3d를 초기화 하고 d3d 디바이스를 생성하는 작업이 우선되어야 한다.
정석적인 d3d 개발의 순서는 다음과 같다
- 생성하고자 하는 윈도우 클래스 등록
- 윈도우를 생성하고 화면에 표시
- 생성된 윈도우의 핸들을 가져와 Direct3D를 초기화
- 메시지 루프 및 루프 종료시 초기화한 D3D를 메모리에서 해제
- 프로그램 종료
여기서 우리는 이미 생성된 윈도우에 후킹을 걸었으므로 1~2의 과정은 불필요하다. 현재 후킹을 걸어놓은 윈도우의 핸들(HWND)만 가져오면 된다.
4번의 메시지 루프는 openGL 이나 GDI때 사용했던 그 메시지를 가리키는게 맞다. OS가 윈도우 프로세스에 보내는 메시지에 따라 동작시키는 부분이다.
현재 우리는 후킹을 진행하여 Endscene 함수 종료시마다 후킹함수를 동작하게 하였으므로 4번과 5번 또한 구현할 필요가 없다.
/* D3D Device 생성 과정*/
HRESULT DeviceManager::Init()
{
//디바이스를 생성하기 위한 D3D 객체 생성
m_pD3D = Direct3DCreate9(D3D_SDK_VERSION);
if (nullptr == m_pD3D)
return E_FAIL;
D3DPRESENT_PARAMETERS d3dpp; //디바이스 생성을 위한 구조체
ZeroMemory(&d3dpp, sizeof(D3DPRESENT_PARAMETERS)); //반드시 ZeroMemory()로 미리 구조체를 깨끗이 지워야 한다
d3dpp.SwapEffect = D3DSWAPEFFECT_DISCARD; //가장 효율적인 SWAP 효과
d3dpp.Windowed = TRUE; //창모드로 생성
d3dpp.BackBufferFormat = D3DFMT_UNKNOWN; //현재 바탕화면 모드에 맞춰서 후면 버퍼 생성
d3dpp.EnableAutoDepthStencil = TRUE;
d3dpp.AutoDepthStencilFormat = D3DFMT_D16;
d3dpp.PresentationInterval = D3DPRESENT_INTERVAL_DEFAULT;
D3DCAPS9 caps;
int vp;
//디바이스를 다음과 같은 설정으로 생성한다
//1. 디폴트 그래픽 카드를 사용한다 (대부분은 그래픽 카드가 1개)
//2. HAL 디바이스를 생성한다 (HW 가속장치를 사용하겠다는 의미)
if (FAILED(m_pD3D->GetDeviceCaps(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, &caps))) //주 그래픽카드의 정보를 D3DCAPS9 에 받아온다
return E_FAIL;
//3. HW가 정점처리를 지원하는지 확인해 지원하는 경우 HW, 그렇지 않을 경우 SW 처리로 생성 (HW로 생성할 경우 더 높은 성능을 냄)
if (caps.DevCaps & D3DDEVCAPS_HWTRANSFORMANDLIGHT)
vp = D3DCREATE_HARDWARE_VERTEXPROCESSING;
else
vp = D3DCREATE_SOFTWARE_VERTEXPROCESSING;
if (FAILED(m_pD3D->CreateDevice(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, g_hWnd, vp, &d3dpp, &m_pD3DDevice)))
return E_FAIL;
return S_OK;
}
3번의 D3D 초기화과정에서는 3D Device 생성을 위해 d3d 객체와 구조체를 선언하고 디바이스의 설정을 제어하는 과정이 들어간다. 그 후 생성된 d3d 객체의 인터페이스인 IDirect3D9을 리턴시키고, 얻어낸 ID3D9 인터페이스로 IDirect3DDevice9을 생성한다.
즉, D3D 객체 - IDirect3D9 인터페이스 - IDirect3DDevice 의 구조를 가진다고 보면 될듯.
그 후 Dircet3D 함수들은 ID3DDevice 의 하위 메소드로 동작하고, 우리가 후킹한 EndScene 함수도 그런식으로 동작한다.
결국 우리가 후킹한 프로그램에서는 객체-인터페이스-디바이스가 이미 모두 생성되어 존재하고 있다는 의미. 만들어져 있으니 새로 만들필요 없이 가져와서 쓰기만 하면 된다.
이때 사용할 방법이 Dummy Device Method이다.
D3D Dummy Device Method
이미 디바이스가 생성되어 돌아가고 있는 프로세스를 후킹했으니, 디바이스를 가져와서 쓰기만 하면된다. 그런데 어떻게?
리버싱을 해봤던 경험으로만 생각하면, Vtable의 위치와 d3d device 하위 메소드들의 인덱스를 모두 알고있으니 거기서 함수의 주소를 가져와서 실행만 하면 되지 않을까? 라고 생각해볼 수 있다. 실제로도 그런식으로 EndScene 함수를 찾아왔으니 말이다.
그러나 이는 범용적으로 쓰기는 어렵다. 함수에 인자를 전달하는 과정등이 필요한데, API의 사용없이 어셈블리만으로 모든 동작을 구현해내는건 굉장히 힘들고 고통스러운 작업이 될 것이다.
그래서 dummy device 를 생성하는 방법을 이용할 것이다. 더미 디바이스를 만들고, 이미 존재하는 vtable 엔트리를 복사하여 마치 디바이스를 만들어서 쓰는 것 처럼 활용할 수 있다.
정리하자면
- 프로세스의 HWND 핸들 받아오기
- 받아온 핸들을 통해 device object 생성
- vTable 주소 가져와 device에 복사
- 복사된 vtable에서 모든 디바이스 오브젝트가 공유됨
- 이미 만들어져 있는 D3DDevice를 직접 사용하는 것과 동일한 효과를 낼 수 있음.
그러므로 더미 디바이스를 이용해 D3D 함수를 써볼텐데, 우선 예제코드를 통해 어떤 식으로 동작이 진행되는지 분석해보자.
후킹 프로세스의 HWND 받아오기
//현재 후킹 걸린 프로세스의 HWND 핸들 가져오기
static HWND window;
BOOL CALLBACK EnumWindowsCallback(HWND handle, LPARAM lParam)
{
DWORD wndProcId;
GetWindowThreadProcessId(handle, &wndProcId);
if (GetCurrentProcessId() != wndProcId)
return TRUE; // skip to next window
window = handle;
return FALSE; // window found abort search
}
HWND GetProcessWindow()
{
window = NULL;
EnumWindows(EnumWindowsCallback, NULL);
return window;
}
GetProcessWindow() 함수부터 보면, 내부적으로 EnumWindows 함수를 호출 하고 window라는 변수를 반환한다.
EnumWindows()
- 현재 실행되어있는 프로세스의 목록, 핸들(HWND)받아오는 함수
- callback 함수를 선언하여 주로 사용함. true 반환시엔 자동으로 다음 프로세스의 HWND를 탐색하고 FALSE 반환시엔 함수 호출을 중단한다.
EnumWindowsCallback 함수는 파라미터로 HWND 핸들을 가져오고, EnumWindwos 함수에서 선언했던 window 변수 값에 접근한다.
GetWindowThreadProcessID
- HWND 핸들을 인자로 ProcessID를 받아옴.
getCurrentProcessID
- 현재 프로세스의 PID를 받아옴. 이 경우는 현재 후킹이 걸려있는 프로세스의 PID를 가져올 것
해당 콜백함수는 false를 반환하기 전까지는 반복해서 실행되는 특성을 가지고 있는데 다음과 같이 동작한다고 정리하자.
- 전체 프로세스 목록을 돌면서 핸들을 찾고
- GetWindowThreadProcessID 함수를 통해 그 핸들을 인자로 PID를 가져 온 다음,
- getCurrentProcessID 로 받아온 현재 후킹이 걸린 프로세스의 PID 또한 가져온다.
- 2와 3에서 찾은 PID를 비교하여 일치하지 않는다면 True를 반환, 다음 프로세스의 핸들값을 가져온다
- 일치한다면 window 변수에 찾은 프로세스의 핸들, 즉 현재 후킹이 걸린 프로세스의 핸들을 저장하고 함수의 실행을 종료한다.
위 코드의 동작을 통해 현재 후킹을 건 프로세스의 HWND 핸들을 받아올 수 있다.
D3D Device Vtable 복사
핸들을 받아왔으니 이제 이를 통해 디바이스 오브젝트를 생성할 차례
bool GetD3D9Device(void ** pTable, size_t Size)
{
if (!pTable)
return false;
IDirect3D9 * pD3D = Direct3DCreate9(D3D_SDK_VERSION);
if (!pD3D)
return false;
IDirect3DDevice9 * pDummyDevice = NULL;
// options to create dummy device
D3DPRESENT_PARAMETERS d3dpp = {};
d3dpp.Windowed = false;
d3dpp.SwapEffect = D3DSWAPEFFECT_DISCARD;
d3dpp.hDeviceWindow = GetProcessWindow();
HRESULT dummyDeviceCreated = pD3D->CreateDevice(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, d3dpp.hDeviceWindow, D3DCREATE_SOFTWARE_VERTEXPROCESSING, &d3dpp, &pDummyDevice);
if (dummyDeviceCreated != S_OK)
{
// may fail in windowed fullscreen mode, trying again with windowed mode
d3dpp.Windowed = !d3dpp.Windowed;
dummyDeviceCreated = pD3D->CreateDevice(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, d3dpp.hDeviceWindow, D3DCREATE_SOFTWARE_VERTEXPROCESSING, &d3dpp, &pDummyDevice);
if (dummyDeviceCreated != S_OK)
{
pD3D->Release();
return false;
}
}
memcpy(pTable, *reinterpret_cast<void***>(pDummyDevice), Size);
pDummyDevice->Release();
pD3D->Release();
return true;
}
void * d3d9Device[119];
if (GetD3D9Device(d3d9Device, sizeof(d3d9Device)))
{
//hook stuff using the dumped addresses
}
GetD3D9Device 함수의 동작을 보자.
- IDirect3D 인터페이스 생성
- IDirect3DDevice 더미 디바이스 생성을 위한 인자값 조정
- HWND 핸들이 필요하기 떄문에 GetProcessWindow() 함수를 호출하여 핸들값을 가져온다.
- 현재 후킹이 걸린 프로세스의 HWND 핸들을 인자로 더미 디바이스 생성
- 이 시점에서 현재 프로세스의 D3D 메소드 vtable 주소를 받아온 셈.
- 인자로 넘어간 pTable 배열에 더미 디바이스의 vTable 배열을 복사
- 생성한 더미 디바이스와 D3D 오브젝트 해제
GetD3D9Device 함수에 포인터 배열을 인자로 넘겨 현재 후킹이 걸린 프로세스의 vtable 배열을 복사해오는 동작을 진행한 것이다. 이제 모든 D3D 메소드 함수들의 함수 포인터를 가져와 사용할 수 있게 되었다.
사실 이렇게 하면 이전에 한 것 처럼 패턴을 탐색하여 vtable을 가져오는 동작을 하지 않아도 된다. DLL 인젝션만 걸면 EndScene 함수의 주소도 가져올 수 있기 때문.
참고 문서
https://bananamafia.dev/post/d3dhook/ - EndScene 후킹
https://guidedhacking.com/threads/hooking-endscene-d3d9.15828/ - EndScene 후킹
https://guidedhacking.com/threads/how-to-hook-functions-code-detouring-guide.14185/ - Detouring 후킹