가상 함수 테이블 보호하기

@codemaru · September 05, 2014 · 9 min read

클라이언트 코드 보안은 여러모로 복마전 같은 느낌이 강하다. CPU가 최종적으로 코드를 실행 시키기 때문에 사실상 해커가 코드를 분석하는 것을 완전하게 막는 방법은 없다. 게다가 현대적인 프로그램들은 이미 그 규모가 어마어마하게 크기 때문에 어떤 지점에서 어떤 공격이 벌어질지 사실상 예측하는 것이 불가능하다고 할 수 있다. 이런 이유로 클라이언트 코드 보안 영역에서는 줄기차게 해커와 경쟁을 벌일 수 밖에 없는 구조다. 상황이 이렇다보니 사실상 코드 보안에 대한 해법 보다는 해커와의 비용 경쟁으로 귀결되는 경우가 많다. 그래서 여기엔 해법(solution) 보다는 완화 방안(mitigation method)이 주를 이룬다.

최근 몇 년 사이 가장 각광받는 클라이언트 코드 공격 기술 중의 하나가 가상 함수 테이블 후킹 기법이다. 이 기법이 유행하게 된 데는 여러가지 이유가 있겠지만 가장 큰 이유는 공격 대상을 찾기가 쉽다는 것과, 한번 찾으면 쉽게 바뀌지 않는다는 점이라고 할 수 있다. 거대한 소프트웨어를 만들기 위해서는 필연적으로 레고 블록 같은 컴포넌트 기술이 발전할 수 밖에 없다. 그런 컴포넌트 기법 중에 사실상 업계 표준이 된 것은 COM이다. COM의 ABI(application binary interface)가 C++의 가상 함수 테이블의 구조로 이루어졌기에 광범위한 곳에서 C++의 가상함수 테이블과 유사한 바이너리 체계를 사용하게 되었다. 컴포넌트 인터페이스가 가지는 또 하나의 주요한 특징은 한번 정의되고 나면 바꾸기가 쉽지 않다는 점이다. 왜냐하면 여기저기 얽혀있는 경우가 많고, 심지어는 외부의 모듈들이 내부 컴포넌트를 참조하는 경우도 있기 때문이다. 이런 사실은 결국 해커 입장에서는 한번 공격하면 오랫동안 지속시킬 수 있다는 것을 의미하기 때문에 공격 비용이 낮아진다는 것과 같은 말이 된다. 해커 입장에서는 아주 빨대를 꽂기에 좋은 지점이지만 방어하는 입장에서는 한두군데가 아니기 때문에 아주 뼈아픈 지점이다.

그럼 실제로 공격이 어떤 방법으로 이루어지고 그걸 또 어떤 방식으로 방어할 수 있는지 알아보도록 하자. [리스트 1] 에는 간단한 가상 함수 테이블을 후킹하는 코드가 나와 있다. ICharacter 클래스는 체력을 구하는 GetHP 함수와, 체력을 설정하는 SetHP 함수를 가지고 있다. main을 살펴보면 캐릭터를 생성하고 10씩 체력을 감소시켜 가기 때문에 코드가 정상적으로 수행됐다면 90부터 0까지 값이 출력되어야 한다. 하지만 예제에서는 GetHP 함수를 HookGetHP로 대체했기 때문에 체력이 줄어들지 않고 모두 100으로 출력된다. 이런 원리로 게임에서 무적 핵이 만들어지곤 한다.

리스트 1 vftable 후킹 예제 소스 코드 다운로드

#include <windows.h>
#include <stdio.h>

class ICharacter
{
public:
	virtual int WINAPI GetHP() = 0;
	virtual void WINAPI SetHP(int hp) = 0;
};

class Romeo : public ICharacter
{
private:
	int hp_;

public:
	Romeo() { hp_ = 100; }
	virtual int WINAPI GetHP() { return hp_; }
	virtual void WINAPI SetHP(int hp) { hp_ = hp; }
};

int WINAPI HookGetHP(PVOID obj)
{
	return 100;
}

void HookVftable(PVOID obj, int vf_idx, PVOID hook_fn, PVOID *or_fn)
{
	ULONG_PTR *xobj = (ULONG_PTR *) obj;
	ULONG_PTR *vft = (ULONG_PTR *) xobj[0];

	if(or_fn)
		*or_fn = (PVOID) vft[vf_idx];

	ULONG oldp;
	VirtualProtect(&vft[vf_idx], sizeof(hook_fn), PAGE_READWRITE, &oldp);
	vft[vf_idx] = (ULONG_PTR) hook_fn;
	VirtualProtect(&vft[vf_idx], sizeof(hook_fn), oldp, &oldp);
}

int main()
{
	ICharacter *chr = new Romeo();
	HookVftable(chr, 0, HookGetHP, NULL);

	for(int i=0; i<10; ++i)
	{
		chr->SetHP(chr->GetHP() - 10);
		printf("%d", chr->GetHP());
		getchar();
	}

	return 0;
}

앞서도 말했지만 이런 코드 공격은 게임에서만 벌어지는 것은 아니다. 인터페이스가 존재하는 모든 프로그램들이 공격 대상이 될 수 있다. 특히나 웹 브라우저 같은 프로그램은 해커에게 아주 좋은 먹잇감이 되곤 한다. Internet Explorer 10 버전부터는 이러한 가상 함수 테이블 변조를 방지하기 위해서 주요 인터페이스 코드에 VTGuard란 것이 적용되었다.

VTGuard의 원리는 간단하다. 가상 함수 테이블 끝에 추가적인 필드를 붙여서 중간의 함수 메소드 값이 변경되었는지 검사하는 방식이다. [그림 1]에 나와 있는 것과 같이 테이블 끝에 vtguard에 해당하는 값을 넣어서 중간에 값이 변경되었는지 검사할 수 있다. vtguard에 VirtualMethod1, 2, 3, 4, …의 CRC 값이 담겨 있다면 앞선 가상 함수들이 호출될 때 테이블의 무결성을 검증할 수 있다. 물론 그 검증하는 코드는 모든 가상 함수의 앞쪽에 붙어 있어야 할 것이다. 물론 이 VTGuard라는 기법 자체도 가상 함수 변조를 완전히 막는 솔루션은 아니다. 왜냐하면 해커가 가상 함수의 값을 변경하고, 그에 맞춰서 vtguard의 값도 업데이트 한다면 우회할 수 있기 때문이다. 서두에 언급한 것과 같이 가상 함수 변조를 조금 더 어렵게 만드는 취약점 완화 방안이라고 생각하면 될 것 같다.

               md 0
그림 1 vtguard 구조

가상 함수 테이블 변조가 프로그램의 심각한 취약점이 되면서 많은 컴파일러들이 VTGuard와 유사한 메커니즘을 속속 추가하고 있다. 보안 프로그래머 입장에서는 새로운 방어 기법과 컴파일러 옵션을 이해하는 것도 중요하지만 가장 원론적인 구성 원리를 체득하기 위해서는 그 메커니즘을 직접 구현해 보는 것이 가장 좋다. [리스트 2]에는 VTGuard를 구현할 수 있는 기본적인 매크로를 추가해 두었다. 살펴보면 가상 함수 끝에 엔트리를 추가하기 위해서 DECL_VT_GUARD라는 것을, vtguard 값의 초기화를 위해서 생성자에는 INIT_VT_GUARD를, 그리고 모든 멤버 함수 앞에는 가상 함수 테이블의 무결성을 검증하기 위한 CHECK_VT_GUARD 매크로가 추가됐다. 각각의 매크로를 구현해서 [리스트 1]의 코드와 연결했을 때 프로그램이 정상적으로 실행되지 않도록 만들어 보자.

리스트 2 VTGuard의 구현

class Romeo : public ICharacter
{
private:
	int hp_;

public:
	Romeo() { INIT_VT_GUARD(this); hp_ = 100; }
	virtual int WINAPI GetHP() { CHECK_VT_GUARD(this); return hp_; }
	virtual void WINAPI SetHP(int hp) { CHECK_VT_GUARD(this); hp_ = hp; }

	DECL_VT_GUARD();
};

덧) 여러분의 해법을 기다리고 있답니다. 썩 잘 만들었다고 생각하신다면 codewiz at gmail dot com으로 보내주세요. 함께 할 만한 일이 있을지도 모르잖아요 ^^;;

@codemaru
돌아보니 좋은 날도 있었고, 나쁜 날도 있었다. 그런 나의 모든 소소한 일상과 배움을 기록한다. 여기에 기록된 모든 내용은 한 개인의 관점이고 의견이다. 내가 속한 조직과는 1도 상관이 없다.
(C) 2001 YoungJin Shin, 0일째 운영 중