dh_0e

[C++/Game Server] Stomp Allocator (Use-after-free & Overflow 오류 검증) 본문

C++/Game Server

[C++/Game Server] Stomp Allocator (Use-after-free & Overflow 오류 검증)

dh_0e 2026. 3. 10. 23:56

Use-After-Free 오류용 Stomp Allocator

new & delete

int main() {
	SYSTEM_INFO info;
	::GetSystemInfo(&info);
	cout << info.dwPageSize << endl;	// 페이지 크기
	cout << info.dwAllocationGranularity << endl;	// 할당 단위

	Knight* test = new Knight;
	test->_hp = 100;
	delete test;
	test->_hp = 200;

	return 0;
}
  • test->_hp=200; 에서 CRASH가 나지 않음
    • new, delete 같은 힙 할당이 유동적으로 메모리를 관리하기 때문에 다음과 같이 에러를 잡지 못하는 경우가 발생

 

VirtualAlloc & VirtualFree

int main() {
	SYSTEM_INFO info;
	::GetSystemInfo(&info);
	cout << info.dwPageSize << endl;			// 페이지 크기
	cout << info.dwAllocationGranularity << endl;		// 할당 단위
    
	int* test = (int*)::VirtualAlloc(NULL, 4096, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
	*test = 100;
	::VirtualFree(test, 0, MEM_RELEASE);
	*test = 200;

	return 0;
}
  • Window api(VirtualAlloc, VirtualFree)를 이용해서 직접 메모리를 사용하고 해제하는 방법
    • OS 레벨에서 돌아가기 때문에 기본적으로 페이지 단위(최소 4KB)로 메모리 할당이 가능함
  • *test = 200;에서 무조건 충돌이 나며 메모리 침범 이슈를 잡아줌
OS가 제공하는 가장 강력한 보안관(페이지 보호 기능)을 고용하기 위해, 그 보안관의 최소 근무 단위인 4KB를 맞춰주는 것

 

StompAllocator

  • Window api를 통한 메모리 할당으로 StompAllocator을 구성
  • 언리얼 엔진에서도 마련이 되어있으며, 광범위하게 사용되는 정책
  • 뭔가를 효율적으로 돌리는 건 아니고 버그를 잡는데 유용함
  • 특히 메모리 오염 버그를 잘 잡아줌

Allocator.h

/*-------------------------
	StompAllocator
-------------------------*/

class StompAllocator {
	// 페이지 단위(4KB)로 할당
	enum { PAGE_SIZE = 0x1000 };

public:
	static void* Alloc(int32 size);
	static void Release(void* ptr);
};
  • 페이지 단위(4KB)로 할당

Allocator.cpp

/*-------------------------
	StompAllocator
-------------------------*/

void* StompAllocator::Alloc(int32 size) {
	const int64 pageCount = (size + PAGE_SIZE - 1) / PAGE_SIZE;
	return ::VirtualAlloc(NULL, pageCount * PAGE_SIZE, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
}

void StompAllocator::Release(void* ptr) {
	::VirtualFree(ptr, 0, MEM_RELEASE);
}

 

  • size가 8byte처럼 작은 경우에도 페이지 단위(4KB)로 할당해 주기 때문에 메모리 낭비가 발생할 수 있음
  • 하지만 메모리 오염 버그를 잡는데 유용하므로 디버깅 모드에서 사용하면 유용함

CoreMacro.h

/*----------------
		Memory
----------------*/

#ifdef _DEBUG
#define xxalloc(size) StompAllocator::Alloc(size)
#define xxrelease(ptr) StompAllocator::Release(ptr)
#else
#define xxalloc(size) BaseAllocator::Alloc(size)
#define xxrelease(ptr) BaseAllocator::Release(ptr)
#endif
  • StompAllocation은 속도도 느리고, 메모리 낭비가 발생할 수 있지만, 메모리 오염 버그를 잡는데 유용하므로 디버깅 모드에서 사용하도록 함

 

GameServer.cpp

class Knight {
public:
	Knight() { cout << "Knight()" << endl; }
	Knight(int32 hp) : _hp(hp) { cout << "Knight(int32 hp): " << hp << endl; }
	~Knight() { cout << "~Knight()" << endl; }

	int32 _hp = 100;
	int32 _mp = 100;
};

int main() {
	Knight* knight = xnew<Knight>(100);

	xdelete(knight);

	knight->_hp = 200;

	return 0;
}
  • knight->_hp = 200; 에서 메모리가 해제된 곳을 접근하려고 한다고 시스템에서 CRASH를 내줌

 

Overflow 오류 검증 추가 Stomp Allocator

GameServer.cpp

class Player {
public:
	Player() { cout << "Player()" << endl; }
	virtual ~Player() { cout << "~Player()" << endl; }
};

class Knight : public Player {
public:
	Knight() { cout << "Knight()" << endl; }
	Knight(int32 hp) : _hp(hp) { cout << "Knight(int32 hp): " << hp << endl; }
	~Knight() { cout << "~Knight()" << endl; }

	int32 _hp = 100;
	int32 _mp = 100;
};

int main() {
	Knight* knight2 = (Knight*)xnew<Player>(); // Player로 메모리 할당 후 Knight으로 강제 형변환

	knight2->_hp = 200; // Overflow 발생

	return 0;
}
  • StompAllocator를 사용하면 메모리 오염을 잡기 위해 메모리 할당을 페이지 단위(4KB)로 함
  • 이 때문에 Player가 할당된 공간 뒤에 아주 작은 약간의 공간(Padding)이 남아있다면 Knight의 _hp가 그 안에 들어가 원래라면 Overflow가 나지만 이를 잡지 못하는 상황이 발생할 수 있음
    • 페이지 단위 [                   ]에서 [ [A]                 ] 다음과 같이 A에 메모리를 할당하면 뒤에 공간에 원래였으면 들어가지 못하는 공간을 _hp의 공간으로 보고 값을 넣어버림
    • 이를 [                 [A]] 이렇게 페이지 경계선에 딱 맞게 메모리 주소를 위치시키면 Underflow는 날 수 있지만 Overflow를 확실하게 잡을 수 있게 됨

Allocator.cpp

/*-------------------------
	StompAllocator
-------------------------*/

void* StompAllocator::Alloc(int32 size) {
	const int64 pageCount = (size + PAGE_SIZE - 1) / PAGE_SIZE;
	const int64 dataOffset = pageCount * PAGE_SIZE - size;
	void* baseAddress = ::VirtualAlloc(NULL, pageCount * PAGE_SIZE, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
	void* dataAddress = static_cast<void*>((int8*)baseAddress + dataOffset);
	return dataAddress;
}

void StompAllocator::Release(void* ptr) {
	const int64 address = reinterpret_cast<int64>(ptr);
	const int64 baseAddress = address - (address % PAGE_SIZE);
	::VirtualFree(reinterpret_cast<void*>(baseAddress), 0, MEM_RELEASE);
}
  • dataOffset을 계산하여 baseAddress에 더해주면 메모리가 할당된 공간(페이지 단위)의 마지막 경계선에 객체가 위치하게 됨
    • size: 8 (Player 객체 크기)
    • pageCount: (8 + 4096 - 1) / 4096 = 1 (1 페이지면 충분함)
    • dataOffset: 1 * 4096 - 8 = 4088
      • 이 4088은 페이지 시작점에서 얼마나 떨어져서 데이터를 배치할 것인가?를 결정하는 값
  • 메모리 해제할 때도 baseAddress를 계산하여 메모리 할당의 시작 주소로 갈 수 있음
    • 나머지(address % PAGE_SIZE): 현재 주소(4088)에서 페이지 크기(4096)로 나눈 나머지, 즉 페이지 시작점으로부터 얼마나 떨어져 있는지(Offset)를 구해줌
    • address - 나머지: 현재 주소에서 그 오프셋만큼 앞으로 돌아가면 정확히 해당 페이지의 시작 주소가 나옴

 

size가 페이지 단위보다 클 경우 (ex. StompAllocator::Alloc(5000))

  • baseAddress: OS가 준 2페이지의 시작점이 0x0000이라고 가정
  • Alloc
    • size: 5000
    • pageCount: (5000 + 4096 - 1) / 4096 = 9095 / 4096 = 2
      • 5000 Byte를 담으려면 1페이지로는 부족하므로 2페이지(8192 Byte)를 할당해야 함
    • dataOffset: 2 * 4096 - 5000 = 8192 - 5000 = 3192
      • 2개의 페이지(총 8192 Byte) 중에서 앞부분 3192 Byte를 비우고, 3192번지부터 데이터를 채우겠다는 뜻
      • 메모리 할당한 부분(8192 Byte)의 경계선까지 데이터를 채울 수 있음
    • return baseAddress + dataOffset = 0 + 3192 = 3192를 return
  • Release
    • address: 3192
    • address % PAGE_SIZE: 3192 % 4096 = 3192
      • 이 나머지가 바로 아까 계산했던 dataOffset과 일치
    • baseAddress: 3192 - 3192 = 0x0000
      • 주소에서 나머지를 빼버리니, 다시 2페이지의 시작점인 0x0000으로 돌아옴

실행 결과 Overflow로 인한 CRASH를 내주는 것을 볼 수 있음