게임핵의 원리 (4) - Speed Hack

스피드 핵(Speed Hack)은 게임의 시간 흐름을 조작하여 속도를 빠르게 하는 핵 기법입니다. API 후킹을 통해 시간 API를 변조하는 원리, QueryPerformanceCounter() 후킹 구현 방법, 그리고 치트 엔진을 이용한 스피드 핵 적용 결과까지 자세히 알아보세요.
Frontier Squad's avatar
Dec 01, 2024
게임핵의 원리 (4) - Speed Hack

이전 포스트


들어가며

Cheat Engine Speedhack
Cheat Engine Speedhack

스피드 핵(Speed Hack)은 프로그램의 속도를 빨라지게 하는 핵으로 위의 치트 엔진(Cheat Engine)을 통해 쉽게 체험할 수 있습니다. 해당 기능을 이용하면 프로세스 내에서 흐르는 시간이 더 빨라지도록 변조하는 효과를 얻을 수 있습니다.


기본 원리

스피드 핵은 시간 측정 시, 측정한 시간에 따라 생성되는 결과를 미래의 시간에 발생할 결과로 변조하여 게임의 속도를 가속합니다. CPU의 처리 속도 자체를 빠르게 하는 것은 아니고, 프로세스의 시간 흐름을 조작하여 게임의 처리 속도를 조절하는 방식입니다. 따라서, 스피드 핵을 적용하여 게임 속도가 빨라지면 처리 속도가 빨라진다는 느낌을 받을 수 있지만, 실제로는 게임의 속도만이 빨라지게 됩니다.

일반적으로 시간을 측정할 때는, 경과 시간(Elapsed time)을 구하기 위해 절대적인 시간 값이 아닌 상대적인 시간(카운터) 값을 사용합니다. 컴퓨터 부팅 이후 지난 시간을 나타내는 GetTickCount() API를 여러 번 호출하여 경과한 시간의 차이를 측정하는 것이 대표적인 예시입니다.

스피드 핵은 API 훅(API Hook)을 설치하고, 시간 API가 호출됐을 때 미래 시간을 반환하여 많은 시간이 지난 것처럼 속이는 방식을 사용합니다. 예를 들어, 첫 호출에서 100ms의 값을 얻었고 두 번째 호출에서 300ms의 값을 얻었다면 실제로 지난 시간은 200ms입니다. 하지만, API 후킹을 통해 두 번째 호출에서 얻어지는 값을 500ms로 설정한다면 게임은 400ms의 시간이 지났다고 인식하여 실제 시간보다 미래에 처리해야 할 작업을 수행하게 됩니다.

다시 말하자면, 스피드 핵은 시간 API가 미래의 시간을 반환하게 만들어 게임을 속인 후 게임이 미래의 작업을 처리하게 하는 프로그램입니다.

아래의 그림을 통해 정상적인 타임라인(위)과 스피드 핵을 사용한 타임라인(아래)을 비교할 수 있습니다.

정상적인 타임라인(위)/스피드 핵을 사용한 타임라인(아래)
정상적인 타임라인(위)/스피드 핵을 사용한 타임라인(아래)

타임라인은 실제 흘러가는 시간을 나타낸 선으로 0 sec부터 10 sec까지 설정되어 있습니다. 여기서 time 0, time 1, …, time 10은 시간 API를 호출했을 때의 시점을 의미하고, task 1, task 2, …, task 11은 게임 내에서 처리하는 작업을 의미합니다. 파란색으로 표기된 0 sec, 1 sec, …, 20 sec 는 시간 API가 반환하는 시간을, task 사이의 1 sec2 sec는주기(interval)를 나타냅니다.

설명을 위해 2 sec에 1개의 task를 처리하는 것을 정상적인 루틴이라고 가정하겠습니다. 이때, 위(정상)와 아래(스피드 핵 사용)는 동일하게 time 0 호출에 따라 0 sec를 반환하여 task 1을 처리합니다.

정상적인 루틴(위)을 먼저 보면, time 0 이후 time 1을 호출했을 때 1 sec의 시간이 흘러갑니다. 이전 호출과 비교했을 때 1 sec가 지났기 때문에(시간 차이가 1 sec) 그 다음 작업을 처리하지 않습니다. time 2를 호출이 일어나면 2 sec를 반환하므로, 2 sec가 지나 task 2를 처리합니다. 마찬가지로, 그 후 3 sec호출 시점에는 task를 처리하지 않고, time 4에서 4 sec를 반환하므로(2 sec가 추가로 흘렀으므로) task 3을 처리합니다. 결과적으로 time 10 호출이 일어나면 10 sec를 반환하고, 실제 시간 역시 0 sec부터 10 sec까지 흘러가므로 이 과정에서 task1부터 task6 까지 6개의 작업을 처리합니다.

스피드 핵을 적용했을 때(아래)는 time 1 호출 시 실제로 흘러간 시간 값인 1 sec가 아닌 2 sec를 반환합니다. 따라서, 실제로 1 sec의 시간이 지났지만 게임은 2 sec의 시간이 지난 것으로 인식하여 task 2를 처리합니다. 이후 time 2 호출 시 4 sec 반환에 따라 게임은 2 sec가 추가로 지난 것으로 인식하여 task 3을 처리합니다. 마찬가지로, 시간 API가 호출되는 1 sec마다 게임은 2 sec가 지난 것으로 인식하여 1 sec마다 각 task를 처리합니다. 결과적으로 time 10 호출이 일어나면 스피드 핵은 20 sec를 반환하고, 0 sec부터 20 sec까지의 시간 흐름에 따라 task1부터 task11 까지 11개의 작업을 처리합니다.

요약하면, 스피드 핵은 API 후킹을 통해 시간의 차이를 2배로 늘려 게임 상의 시간을 2배로 흐르도록 만들고, 그 결과로 게임이 2배의 작업을 처리하여 처리량(throughput)이 2배로 증가합니다.


API 후킹

게임은 일반적으로 GetTickCount(), timeGetTime(), QueryPerformanceCounter() Win API를 사용하여 시간을 측정합니다. 따라서, API 후킹을 통해 해당 함수의 결과를 변조하면 스피드 핵을 구현할 수 있습니다.

GetTickCount

DWORD GetTickCount();
ULONGLONG GetTickCount64();

GetTickCount()는 컴퓨터를 부팅한 시점으로부터 흐른 시간을 반환하는 함수로, 밀리초(millisecond, ms)를 단위로 사용합니다. 32비트로 값을 표현하기 어려운 경우, GetTickCount64() API를 이용해서 더 큰 범위를 표현할 수 있습니다.

Reference

timeGetTime

DWORD timeGetTime();

timeGetTime() API도 GetTickCount()와 동일하게 부팅 시점부터 흐른 시간을 반환하며, 밀리초 단위를 사용합니다. 해당 API는 GetTickCount()보다 높은 정밀도(Resolution)를 사용할 수 있다는 특징을 가집니다. 정밀도에 대한 설명은 아래와 같습니다.

MSDN — GetTickCount() Remarks The resolution of the GetTickCount function is limited to the resolution of the system timer, which is typically in the range of 10 milliseconds to 16 milliseconds.

MSDN — timeGetTime() Remarks The default precision of the timeGetTime function can be five milliseconds or more, depending on the machine. You can use the timeBeginPeriod and timeEndPeriod functions to increase the precision of timeGetTime.

위 내용에 따르면 GetTickCount()는 10ms~16ms 단위로 업데이트된 값을 얻을 수 있는 반면, timeGetTime()은 업데이트 주기를 1ms로 설정할 수 있습니다.

Reference

QueryPerformanceCounter

BOOL QueryPerformanceCounter(
  LARGE_INTEGER *lpPerformanceCount
);
BOOL QueryPerformanceFrequency(
  LARGE_INTEGER *lpFrequency
);

QueryPerformanceCounter()는 누적된 CPU 클럭 수를, QueryPerformanceFrequency()는 초당 CPU 클럭 수를 반환합니다. 이를 이용하면, 마이크로초(microsecond, us) 단위로 시간을 측정할 수 있습니다. 일반적으로 시간은 카운터값을 QueryPerformanceFrequency()값으로 나누어 계산합니다. 해당 API는 높은 정밀도로 인해 게임에 자주 사용되므로, 본 포스트에서 예제로 사용하겠습니다.

Reference


예제

대상 코드 1

int main()
{
	LARGE_INTEGER frequency,count;
	QueryPerformanceFrequency(&frequency);
	while (1)
	{
		QueryPerformanceCounter(&count);
		double current_time = (double)count.QuadPart / (double)frequency.QuadPart;
		printf("time : %llf\\n", current_time);
		Sleep(1000);
	}
}

위의 예시는 1초마다 현재 시간(시간 카운터)을 출력하는 코드입니다. QueryPerformanceCounter()로 구한 카운터값을 QueryPerformanceFrequency()로 구한 frequency값으로 나눠 시간을 측정합니다.

대상 코드 2

int main()
{
	LARGE_INTEGER frequency, prev, current;
	QueryPerformanceFrequency(&frequency);
	int i = 0;
	while (1)
	{
		QueryPerformanceCounter(&prev);
		printf("count : %d\\n", i);
		double elapsed;
		do
		{
			QueryPerformanceCounter(¤t);
			elapsed = (double)(current.QuadPart - prev.QuadPart) / (double)frequency.QuadPart;
		} while (elapsed <= 1);
		i++;
	}
}

위의 예시는 1초마다 증가하는 카운터를 출력하는 코드입니다. QueryPerformanceCounter()Sleep()을 구현하여 시간에 따라 작업을 반복한다는 점이 대상 코드 1과의 차이입니다.


치트 엔진의 후킹

치트 엔진에서 스피드 핵을 활성화하고 대상 프로세스의 시간 API를 확인하면, 아래와 같이 인라인 훅(Inline Hook)의 설치를 확인할 수 있습니다.

GetTickCount() 인라인 훅
GetTickCount() 인라인 훅

timeGetTime() 인라인 훅
timeGetTime() 인라인 훅

QueryPerformanceCounter() 인라인 훅
QueryPerformanceCounter() 인라인 훅

이를 바탕으로 후킹을 따라가면, 아래와 같은 후킹 함수 코드를 확인할 수 있습니다.

QueryPerformanceCounter() 인라인 훅 구
QueryPerformanceCounter() 인라인 훅 구

후킹 설치 코드

아래는 QueryPrerformanceCounter()(이하 QPC)의 후킹 설치 함수입니다.

LARGE_INTEGER prev, fake;
void HookQpcInternal()
{
	QueryPerformanceCounter(&prev); //초기화
	fake = prev; //초기화
	DetourFunction((PBYTE)QueryPerformanceCounter, (PBYTE)hkQueryPerformanceCounter, 5, (void**)&oQueryPerformanceCounter); //QPC를 후킹한다.
}

코드를 살펴보면, 먼저 QueryPerformanceCounter(&prev)prev, fake값을 초기화합니다. 이후, DetourFunction() 후킹 설치 함수[참고: 게임핵의 원리(1) - Wall Hack]를 사용하여 API를 후킹하고 oQueryPerformanceCounter에 QPC 원본 함수의 주소를 저장합니다.

이때 QPC 함수는 게임에서 매우 빈번하게 사용하는 함수이므로, 스피드 핵은 후킹 설치 시 안정성을 고려해야 합니다. 예를 들어 후킹 설치와 QPC 원본 함수 주소 저장 시점 사이에 게임이 QPC를 호출할 경우, 원본 함수의 주소가 저장되어 있지 않아 오류가 발생할 수 있습니다. 이러한 문제를 방지하기 위해서는 안전한 후킹 설치 함수를 사용해야 합니다.

이외에도 훅을 설치하는 DLL의 쓰레드 외에 모든 쓰레드를 정지시키는 방법이 있습니다. 일반적으로 SuspendThread()(참고: MSDN)로 자신 외의 쓰레드를 멈춘 후에 훅을 설치하고 ResumeThread()(참고: MSDN)로 다시 재개합니다.

안정적인 후킹 라이브러리로는 MinHook, Detours가 대표적입니다. MinHook은 후킹을 설치할 때 내부적으로 쓰레드를 일시정지 및 재개하는 방식을 사용하고, Detours는 Transaction Begin 및 Commit을 통해 후킹을 안정적으로 설치하도록 지원하며 쓰레드 일시정지 기능 또한 제공합니다.

후킹 함수

아래는 후킹 함수를 구현한 예시입니다.

LARGE_INTEGER prev, fake;
float accelerator = 20;
typedef HRESULT(WINAPI* tQueryPerformanceCounter)(LARGE_INTEGER *counter);
tQueryPerformanceCounter oQueryPerformanceCounter = NULL;
HRESULT WINAPI hkQueryPerformanceCounter(LARGE_INTEGER *counter)
{
 LARGE_INTEGER current;
 oQueryPerformanceCounter(¤t); // [1] QPC 원본 함수 호출
 LONGLONG elapsed = current.QuadPart - prev.QuadPart; // [2] 시간차 계산
 fake.QuadPart += elapsed * accelerator; // [3] 가속된 시간차를 반영
 prev = current; // [4] 이전 시간 업데이트
 *counter = fake; // [5] 결과 저장
 return true;
}

[1] : 가장 먼저 oQueryPerformanceCounter(¤t)로 QPC 원본 함수를 호출하여 current에 저장합니다.

[2] : 저장된 현재 시간(CPU 클럭 카운터)과 이전에 측정된 시간을 뺀 값을 elapsed에 저장합니다. 이 값은 QPC가 호출된 가장 최근 시점부터 현재까지의 걸린 시간(시간 차)을 의미합니다.

[3] : 시간 차를 accelerator값과 곱하여 fake값에 더합니다. 가속도를 곱해서 더하는 해당 과정이 스피드 핵의 핵심입니다.

[4] : prev값을 현재 current값으로 업데이트합니다.

[5] : 결과로 쓰일 *counter 인자에 가속한 시간 값인 fake값을 할당합니다.

결과적으로, 후킹 함수는 *counter값을 가속된 값으로 설정하여 코드가 QPC 함수를 호출할 때 시간을 미래의 시간으로 인식하도록 합니다. 위의 예제에서는 accelerator20이므로 시간이 20배 빨라지는 결과를 얻을 수 있습니다.


결과

대상 코드 1

핵 적용 전: 시간(카운터)이 1초씩 증가합니다.

시간 증가

핵 적용 후: 1초씩 증가했던 시간이 20배 가속되어 20초씩 증가합니다.

시간 증가(가속)

대상 코드 2

핵 적용 전: 1초마다 카운터를 출력합니다.

카운터 출력

핵 적용 : 빠른 속도로 카운터를 출력합니다.

카운터 출력(가속)


마치며

스피드 핵은 API 후킹을 통해 시간을 미래 시점의 시간으로 속여 게임 상의 처리를 가속하는 프로그램입니다. 역사가 오래된 핵이지만, API 후킹으로 구현할 수 있기 때문에 게임을 따로 분석하지 않아도 전역으로 적용하여 다양한 장르의 게임에 사용할 수 있다는 장점이 있습니다.


About Theori Security Assessment

티오리 Security Assessment 팀은 실제 해커들의 오펜시브 보안 감사 서비스를 통해 고객의 서비스와 인프라스트럭처를 안전하게 함으로써 비즈니스를 보호합니다. 특히, 더욱 안전한 세상을 위해 난제급 사이버보안 문제들을 해결하는 것을 즐기며, 오펜시브 사이버보안의 리더로서, 공격자보다 한발 앞서 대응하고 불가능하다고 여겨지는 문제를 기술중심적으로 해결합니다.

Share article

관련 콘텐츠

See more posts

Theori © 2025 All rights reserved.