본문 바로가기
게임 보안/[서적] 봇을 이용한 게임 해킹

2. 코드에서 메모리로 - 기본 원리

by 헛둘이 2022. 9. 24.

본 게시글은 메모리에 대한 깊은 이해를 위해 에이콘 출판사의 "봇을 이용한 게임 해킹" 서적을 보고 필기한 자료입니다.

따라서 디테일한 부분에서 본 서적의 실제 내용과 다를 수 있고 글쓴이의 주관이 들어가 있음을 참고해주시기 바랍니다.

문제 시 비공개 처리하도록 하겠습니다.

.

 

근본적으로 게임 코드, 데이터, 입력값과 출력값과 같은 개념들은 메모리 바이트를 변형시킨 개념들


  • 게임 해커들은 게임 데이터를 조작함으로써 게임에서 사람에게 도움이 되는 이익을 챙긴다.
  • 이를 위해선 개발자의 코드가 컴파일되고 실행될 때 사람이 어떻게 인지하고 어떤 영향을 미치는지 이해해야 함

 

컴퓨터처럼 사고하기 위해서
  • 숫자와 텍스트, 간단한 구조체와 복합체들이 어떻게 메모리에 바이트 단위로 표시되는지 알아야 함

 

 

숫자 데이터
  • 플레이어의 마나, 체력, 위치, 레벨 등이 숫자로 표시됨
  • 숫자 데이터는 다른 유형의 데이터보다 직관적
  • 배열의 위치가 정해져 있고, 고정된 비트 너비를 가짐
숫자 데이터 유형의 크기는 구조체나 컴파일러에 따라 달라질 수 있고, 이 책은 x86 (32비트)로 설명
  • float를 제외하고 나머지 데이터들은 리틀 엔디안 방식으로 저장됨

 

리틀 엔디안이란?

가장 작은 정수부터 가장 낮은 주소에 저장되는 방식
0x0A0B0C0D -> 0x0D 0X0C 0x0B 0x0A
  • float형은 숫자가 혼합된 형태로 나타남

 

float형 변수값 1337.7331가 메모리에 매핑될 때
  1. 1337 + 0.7331로 구분하기
  2. 1337을 2진수로 변환 -> 0101 0011 1001
  3. 0.7331을 2진수로 변환 -> 1011 1011 1010 1100 0111 0001
  4. 두 수를 합침 -> 0101 0011 1001 . 1011 1011 1010 1100 0111 0001
  5. 정규화 하기 -> 1.01 0011 1001 1011 1011 1010 1100 0111 0001 e10
  6. 지수부에 Bias(127)를 더해서 137(10 + 127)을 2진수로 변환 -> 1000 1001
  7. 부호비트 1비트(0) + 지수부(1000 1001)에 정수부를 뺀 가수부를 붙이기 

부호비트1비트              지수부8비트                     가수부 23비트

=>     0                      10001001        01001110011011101110101

 

 

 

float형이 가수부가 23비트다보니 꽤 큰 오차가 있었다.

 

가수부를 직접 구해보고 싶어서 소수점 부분을 손으로 직접 계산해봤다..

 


 

 

 

구조체
  • 구조체는 상대적으로 간단하고 적은 양의 데이터를 가진 컨테이너
  • 구조체를 파악하고 있는 해커는 구조체를 모방해서 구현할 수 있다.
  • 모든 개별 아이템마다 주소를 할당하는 게 아닌 구조체의 처음에만 할당하는 식
#include <iostream>
struct MyStruct
{
    unsigned char ubyteValue;
    char byteValue;
    unsigned short uwordValue;
    short wordValue;
    unsigned int udwordValue;
    int dwordValue;
    unsigned long long ulongLongValue;
    long long longLongValue;
    float floatValue;
};

MyStruct* m = new MyStruct{};

int main()
{
    printf("Offset: \n 1번째 - %p\n 2번째 - %p\n 3번째 - %p\n 4번째 - %p\n 5번째 - %p\n 6번째 - %p\n 7번째 - %p\n 8번째 - %p\n 9번째 - %p\n",
        (char*)&m->ubyteValue - (char*)&m->ubyteValue,
        (char*)&m->byteValue - (char*)&m->ubyteValue,
        (char*)&m->uwordValue - (char*)&m->ubyteValue,
        (char*)&m->wordValue - (char*)&m->ubyteValue,
        (char*)&m->udwordValue - (char*)&m->ubyteValue,
        (char*)&m->dwordValue - (char*)&m->ubyteValue,
        (char*)&m->ulongLongValue - (char*)&m->ubyteValue,
        (char*)&m->longLongValue - (char*)&m->ubyteValue,
        (char*)&m->floatValue - (char*)&m->ubyteValue);
}
  • 멤버의 주소는 ubyteValue가 0번지이므로, 현재 offset에서 ubyteValue를 빼줌으로써 오프셋을 가져올 수 있도록 했다.

Offset:
 1번째 - 00000000
 2번째 - 00000001
 3번째 - 00000002
 4번째 - 00000004
 5번째 - 00000008
 6번째 - 0000000C
 7번째 - 00000010
 8번째 - 00000018
 9번째 - 00000020

 

  • 결과값에서 보이는 바와 같이 코드에서 나열된 순서대로 출력됨
  • 멤버 레이아웃은 구조체의 필수 속성임
  • 구조체 멤버는 구조체 패딩이 적용되는데, 맞춤 크기는 컴파일러 옵션에 따라 다르다. (지금은 4)
  • ulongLongValue 다음에 char를 배치하면 마지막 오프셋이 32에서 36으로 바뀌는 것을 확인할 수 있다.
메모리에서 데이터를 읽어올 때 구조체를 만들어서 읽어오면 편리함
체력과 마력이 있을 때 보통 구조체로 엮여 있으므로 가져올 때도 구조체로 가져옴

 

 

 

 

 

 


유니온
  • 유니온은 세 가지 규칙을 따른다.
    1. 메모리상에서 유니온의 크기는 가장 큰 멤버의 크기와 같다.
    2. 유니온의 멤버는 동일한 메모리 레퍼런스를 갖는다.
    3. 유니온은 가장 큰 멤버의 정렬을 상속받는다.
#include <iostream>
#include <Windows.h>

union 
{
    BYTE byteValue;
    struct {
        WORD first;
        WORD second;
    } words;

    DWORD value;
} dwValue;


int main()
{
    dwValue.value = 0xDEADBEEF;

    printf("Size %d\nAddresses 0x%x, 0x%x\nValues 0x%x, 0x%x\n",
        sizeof(dwValue), &dwValue.value, &dwValue.words,
        dwValue.words.first, dwValue.words.second);
}

Size 4
Addresses 0x615fc17c, 0x615fc17c
Values 0xbeef, 0xdead

 

 

  • 첫 줄에 출력된 Size값으로 1번과 2번 규칙 설명이 가능함
  • DWORD value = 0xDEADBEEF일 때
  • words.first가 BEEF이고, words.second가 DEAD인 이유는
  • 리틀 엔디안으로 ef be ad de로 저장되고,
  • word.first가 ef be를 beef로 읽어오고 word.second가 ad de를 dead로 읽어오는 것

 

 

 

 


클래스와 VF테이블
// 일반 클래스
#include <iostream>
#include <Windows.h>

class bar {
public:
	bar()
		: bar1(0x898989)
		, bar2(0x10203040)
	{}
	
	void myfunction() { bar1++; }
	int bar1, bar2;
};

int main()
{
	bar _bar = bar();
	printf("Size %d; Address 0x%x : _bar\n", sizeof(_bar), &_bar);
}


Size 8; Address 0xa1f918 : _bar

 

  • 일반 클래스는 구조체와 같이 가진 멤버 변수의 바이트로 사이즈가 결정된다.

 

// 가상함수가 포함된 클래스
#include <iostream>
#include <Windows.h>

class foo
{
public:
	foo()
		: myValue1(0xDEADBEEF)
		, myValue2(0xBABABABA) {}
	

	int myValue1;
	static int myStaticValue;

	virtual void bar() { printf("call foo::bar()\n"); }
	virtual void baz() { printf("call foo::baz()\n"); }
	virtual void barbaz() {}

	int myValue2;
};

int foo::myStaticValue = 0x12121212;

class fooa : public foo
{
public:
	fooa() : foo() {}
	virtual void bar() { printf("call fooa::bar()\n"); }
	virtual void baz() { printf("call fooa::baz()\n"); }
};

class foob : public foo
{
public:
	foob() : foo() {}
	virtual void bar() { printf("call foob::bar()\n"); }
	virtual void baz() { printf("call foob::baz()\n"); }
};



int main()
{
	foo* _testfoo = (foo*)new fooa();
	_testfoo->bar();

	delete _testfoo;
}
  • 가상 함수가 포함된 클래스는 인스턴스의 0번지에 VF테이블을 가리키는 포인터가 포함되어 있다.
  • 그래서 사이즈도 멤버 사이즈 + 포인터의 크기임
  • VF테이블은 바이너리가 생성될 때 컴파일러에 의해 지속적으로 유지됨
  • 공간을 절약하기 위해 클래스 인스턴스들은 동일한 VF테이블을 가리킴
  • 이게 어떻게 가능할까?

 

컴파일러는 어셈블리처럼 보이는 다음 코드를 가상 클래스 생성자에 포함시킨다

mov dword ptr ds:[EAX], VFADDR

이 코드를 통해 VF테이블의 정적 주소 VFADDR을 가져오게 되며, 인스턴스의 첫 멤버에 위치시킴

VFTABLE을 넣는 어셈블리 코드, 실제 주소를 따라가보니 _fooa가 사용할 가상함수의 테이블이 존재함
_fooa의 0번째 주소에 0x00869b70가 담긴 포인터 변수가 있다
포인터가 가리키는 0x00869b70 주소를 따라가보니 가상함수 테이블이 있는 걸 확인했음

 

'게임 보안 > [서적] 봇을 이용한 게임 해킹' 카테고리의 다른 글

6. 코드 인젝션  (1) 2022.10.01
5. 게임 메모리 읽고 쓰기  (1) 2022.09.29
4. 고급 메모리 포렌식  (0) 2022.09.28
3. x86 어셈블리 크래시 코스  (1) 2022.09.28
1. 해킹 도구  (0) 2022.09.18

댓글