본 게시글은 메모리에 대한 깊은 이해를 위해 에이콘 출판사의 "봇을 이용한 게임 해킹" 서적을 보고 필기한 자료입니다.
따라서 디테일한 부분에서 본 서적의 실제 내용과 다를 수 있고 글쓴이의 주관이 들어가 있음을 참고해주시기 바랍니다.
문제 시 비공개 처리하도록 하겠습니다.
.
근본적으로 게임 코드, 데이터, 입력값과 출력값과 같은 개념들은 메모리 바이트를 변형시킨 개념들
- 게임 해커들은 게임 데이터를 조작함으로써 게임에서 사람에게 도움이 되는 이익을 챙긴다.
- 이를 위해선 개발자의 코드가 컴파일되고 실행될 때 사람이 어떻게 인지하고 어떤 영향을 미치는지 이해해야 함
컴퓨터처럼 사고하기 위해서
- 숫자와 텍스트, 간단한 구조체와 복합체들이 어떻게 메모리에 바이트 단위로 표시되는지 알아야 함
숫자 데이터
- 플레이어의 마나, 체력, 위치, 레벨 등이 숫자로 표시됨
- 숫자 데이터는 다른 유형의 데이터보다 직관적
- 배열의 위치가 정해져 있고, 고정된 비트 너비를 가짐
숫자 데이터 유형의 크기는 구조체나 컴파일러에 따라 달라질 수 있고, 이 책은 x86 (32비트)로 설명
- float를 제외하고 나머지 데이터들은 리틀 엔디안 방식으로 저장됨
리틀 엔디안이란?
가장 작은 정수부터 가장 낮은 주소에 저장되는 방식
0x0A0B0C0D -> 0x0D 0X0C 0x0B 0x0A
- float형은 숫자가 혼합된 형태로 나타남
float형 변수값 1337.7331가 메모리에 매핑될 때
- 1337 + 0.7331로 구분하기
- 1337을 2진수로 변환 -> 0101 0011 1001
- 0.7331을 2진수로 변환 -> 1011 1011 1010 1100 0111 0001
- 두 수를 합침 -> 0101 0011 1001 . 1011 1011 1010 1100 0111 0001
- 정규화 하기 -> 1.01 0011 1001 1011 1011 1010 1100 0111 0001 e10
- 지수부에 Bias(127)를 더해서 137(10 + 127)을 2진수로 변환 -> 1000 1001
- 부호비트 1비트(0) + 지수부(1000 1001)에 정수부를 뺀 가수부를 붙이기
부호비트1비트 지수부8비트 가수부 23비트
=> 0 10001001 01001110011011101110101
구조체
- 구조체는 상대적으로 간단하고 적은 양의 데이터를 가진 컨테이너
- 구조체를 파악하고 있는 해커는 구조체를 모방해서 구현할 수 있다.
- 모든 개별 아이템마다 주소를 할당하는 게 아닌 구조체의 처음에만 할당하는 식
#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으로 바뀌는 것을 확인할 수 있다.
메모리에서 데이터를 읽어올 때 구조체를 만들어서 읽어오면 편리함
체력과 마력이 있을 때 보통 구조체로 엮여 있으므로 가져올 때도 구조체로 가져옴
유니온
- 유니온은 세 가지 규칙을 따른다.
- 메모리상에서 유니온의 크기는 가장 큰 멤버의 크기와 같다.
- 유니온의 멤버는 동일한 메모리 레퍼런스를 갖는다.
- 유니온은 가장 큰 멤버의 정렬을 상속받는다.
#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을 가져오게 되며, 인스턴스의 첫 멤버에 위치시킴
'게임 보안 > [서적] 봇을 이용한 게임 해킹' 카테고리의 다른 글
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 |
댓글