본 게시글은 메모리에 대한 깊은 이해를 위해 에이콘 출판사의 "봇을 이용한 게임 해킹" 서적을 보고 필기한 자료입니다.
따라서 디테일한 부분에서 본 서적의 실제 내용과 다를 수 있고 글쓴이의 주관이 들어가 있음을 참고해주시기 바랍니다.
문제 시 비공개 처리하도록 하겠습니다.
프로그램의 소스 코드가 바이너리로 컴파일 될 때 불필요한 모든 부분이 제거되고 기계어로 바뀐다.
- 기계어는 오직 바이트로만 구성됨
- 기계어는 프로세서에 직접 피드백을 제공하고, 명령한다.
- 0과 1로 트랜지스터의 상태를 변경하는 과정을 이해하는 건 어렵기 때문에
- 좀 더 쉽게 소통하기 위해서 어셈블리와 같은 기계어로 작업을 수행함
어셈블리 언어
- 니모닉스라고 부르는 짧은 형태의 명령어와 피연산자로 표현하며 문법이 매우 간단
- 게임 해커에게 가장 중요한 프로그램 언어는 바로 이 어셈블리 언어
- 해킹툴과 기법이 nop를 활용하거나 후킹처럼 게임 내 어셈블리 코드를 직접 조작하기 때문
어셈블리어는 문법이 아주 간단하다
if (EBX > EAX)
ECX = EDX
else
ECX = 0
// 이런 간단한 코드가 어셈블리로 변환되면?
CMP EBX, EAX
JG label1
MOV ECX, 0
JMP label2
label1:
MOV ECX, EDX
label2:
- 어셈블리의 세세한 기능을 파악하려면 꾸준한 연습이 필요하다.
어셈블리 인스트럭션
- 위에서 표기한 CMP, JG, MOV, JMP 모두 인스트럭션임
피연산자
- 피연산자의 종류는 크게 세 가지 형식으로 구분됨
- 즉싯값 : 그 때 그 때 선언되는 정수값
- 레지스터 : 프로세스 레지스터의 이름
- 메모리 오프셋 : 값의 메모리 위치를 표현하는 것이며, 대괄호 안에 위치함
일반적으로 2개의 피연산자를 갖는 경우가 대부분인데,
첫 번째 피연산자를 출발지 피연산자, 두 번째 피연산자를 목적지 피연산자라고 한다.
MOV R1, 1 // R1(레지스터)를 1로 설정 => register r1 = 1;
MOV R1, [BADF00Dh] // R1의 값을 [BADF00Dh]에 위치한 값으로 설정 => register r1 = *(BADF00D);
MOV R1, [R2+10h] // R1의 값을 [R2+10h](메모리 오프셋)에 위치한 값으로 설정 => register r1 = *(&r2 + 10h);
- 인텔 문법에선 목적지 연산자가 먼저 배치되고, 그 다음 출발지 연산자가 배치됨
PUSH EBP // EBP가 가지고 있던 값을 스택에 넣음
MOV EBP, ESP // EBP에 ESP의 값을 덮어씀
PUSH -1 // 스택에 -1을 집어넣음
ADD ESP, 4 // ESP를 되돌리기 위해 ESP의 위치에 4를 더함
MOV ESP, EBP // ESP에 EBP를 넣음 (ESP를 함수 시작 시 초기값으로 변경)
POP EBP // 스택에 넣어놨던 EBP 복구
XOR EAX, EAX // EAX를 0으로 세팅
RETN // 리턴
// C언어버전
STACK stack;
REGISTER EBP;
REGISTER ESP;
REGISTER EAX;
stack.push(EBP); ESP -= 4;
EBP = ESP;
stack.push(-1); ESP -= 4;
ESP += 4;
ESP = EBP;
EBP = stack.pop();
EAX = 0;
return EAX;
- 이 코드는 전형적인 스택프레임 프롤로그, 에필로그에 해당하는 코드임
프로세서 레지스터
- 하이레벨 프로그래멍 언어와 달리 어셈블리에선 사용자 지정 변수이름을 사용하지 않음
- 대신 메모리 주소를 레퍼런스함으로써 직접 데이터에 접근
- 집중적으로 연산이 수행되는 동안 RAM의 데이터를 읽고 쓰는 오버헤드에 의한 비용 발생
X86 프로세서에선 이런 오버헤드를 완화하기 위해 조그만 임시 변수들을 제공하는데 그게 프로세서 레지스터임
RAM에 바로 접근하는 것보다 프로세서 레지스터에 접근하는 것이 오버헤드를 덜 발생시킴
범용 레지스터
- 임의의 데이터를 저장하거나 연산할 때 사용하는 레지스터
- 이 레지스터는 함수의 로컬변수와 같은 데이터를 저장하는 목적으로 사용함
- 모든 범용 레지스터는 32비트이며 DWORD변수로 처리함
- EAX : 수학 연산에 최적화, 곱셈이나 나눗셈 담당함
- EBX : 특정 목적의 추가 저장 공간(메모리 주소 레퍼런스)
- ECX : 하이레벨 언어에서 for문의 i 역할
- EDX : EAX를 보조하는 역할, 64비트 연산을 할 때 EAX는 0~31비트 사이 연산 수행 / EDX는 32~64비트 연산 수행
32비트 범용 레지스터는 하나의 유니온과 같음
union
{
DWORD EAX;
WORD AX;
struct{
BYTE L;
BYTE H;
} A;
} EAX;
- 자신의 일부를 서브레지스터로 제공
인덱스 레지스터
- X86 어셈블리에는 로컬 정보를 저장하고 있는 4개의 인덱스 레지스터가 있음
- 범용 레지스터와 마찬가지로 32비트로 구성
- 하지만 다음과 같이 범용 레지스터보다 제한적
- EDI : 쓰기 대상이 되는 메모리를 인덱싱하는데 사용
- ESI : 읽기 대상이 되는 메모리를 인덱싱하는데 사용
- ESP : 스택의 최상부를 가리킴, 모든 스택 연산은 ESP를 거쳐감
- EBP : 스택 프레임의 가장 낮은 부분을 가리키는데 사용
왜 인덱스 레지스터만 서브 레지스터(2바이트, 1바이트)가 없을까?
=>인덱스 레지스터는 애초에 메모리 주소 오프셋으로 사용되기 때문에 일부 바이트 값을 알 필요가 없음
명령어 인덱스 레지스터
- EIP : 프로세서에 의해 실행되고 있는 코드의 주소를 가리킴
EFLAGS 레지스터
- 어셈블리 언어의 비교 구문이 따로 없어서 CMP의 결과값으로 플래그를 세팅하는 레지스터
- 주로 0, 2, 4, 6, 7, 11비트를 사용함
- 0: Carry - 이전 명령이 수행되는 동안 최상위 비트에서 올림수, 빌림수가 발생한 경우 세팅
- 2: Parity - 이전 명령으로 인해 생성된 유효 바이트가 짝수 비트일 때 세팅
- 4: Adjust - Carry와 동일하지만 최하위 4비트만 고려함
- 6: Zero - 이전 명령에서 0이 나온 경우 세팅
- 7: Sign - 이전 명령에서 최상위 비트가 양수 혹은 음수를 갖느냐에 따라 설정
- 11: Overflow - 이전 명령어 수행으로 오버플로우 났을 때 세팅
세그먼트 레지스터
- 16비트 레지스터이고, 다른 레지스터와 같이 데이터를 저장하는 목적으로 사용되지는 않음
- 어디에 저장할 지 그 장소를 가리키는 목적으로 사용
- CS: 코드 영역
- DS: 데이터 영역
- ES, FS, GS: OS에서 사용하는 메모리 세그먼트
- SS: 콜 스택에 할당되어 있는 메모리 영역
PUSH DS:[EBP]와 같이 사용하며, 이게 없다면 DS:가 생략되어 있는 것이다.
콜 스택
- 레지스터는 강력한 기능을 제공하지만 사용하는데 제약이 있음
- 어셈블리 코드를 효율적으로 저장하려면 콜 스택을 사용해야 함
- 콜 스택은 함수의 파라미터, 반환 주솟값, 일부 지역 변수 저장 가능
- 콜 스택의 입출력을 이해하는 것은 리버스 엔지니어링을 수행하는데 도움이 많이 된다.
콜 스택이란?
어셈블리 코드가 직접 접근 및 조작하는 DWORD값의 FILO 리스트
PUSH에 의해 데이터가 추가되며, POP에 의해 제거된다.
윈도우에서 스택은 높은 주소에서 낮은 주소로 자란다.
가장 낮은 주소 n부터 0x0000'0000까지 쌓일 수 있음
스택의 최상부를 가리키는 esp는 스택에 아이템이 추가될수록 값이 감소하고 제거될수록 커짐
게임 해킹에서 중요한 x86 인스트럭션
데이터 수정
- 데이터를 수정하는 작업은 어셈블리의 여러 연산을 통해 수행됨
- 그리고 그 결과는 메모리 혹은 레지스터에 전달됨
- 가장 대표적으로 mov
MOV R1, R2
=> REGISTER R1 = R2;
MOV R1, [R2]
=> REGISTER R1 = *(R2);
MOV R1, [R2+Ah]
=> REGISTER R1 = *(R2 + 0xA);
MOV R1, [DEADBEEFh]
=> REGISTER R1 = *(0xDEADBEEF);
MOV R1, BADF00Dh
=> REGISTER R1 = 0xBADF00D;
MOV [R1], R2
=> *R1 = R2;
MOV [R1], BADF00Dh
=> *R1 = 0xBADF00D;
MOV [R1+4h], R2
=> *(R1 + 0x4) =R2;
MOV [R1+4h], BADF00Dh
=> *(R1 + 0x4) = 0xBADF00D;
MOV [DEADBEEFh], R1
=> *(0xDEADBEEF) = R1;
MOV [DEADBEEFh], BADF00Dh
=> *(0xDEADBEEF) = 0xBADF00D;
- 책에 씌여있는 어셈 코드를 C 코드로 변환해보았다.
산술 연산
단항 산술 연산
- INC : 피연산자 값에 1을 더함
- DEC : 피연산자 값에 1을 뺌
- NOT : 피연산자 비트 반전
- NEG : 2의 보수로 변환 (결과적으로 -1을 곱한 것과 같음)
이항 산술 연산
- ADD : 출발지 += 목적지
- SUB : 출발지 -= 목적지
- AND : 출발지 &= 목적지
- OR : 출발지 |= 목적지
- XOR : 출발지 ^= 목적지
- SHL : 출발지 <<= 목적지
- SHR : 출발지 >>= 목적지
- IMUL : 출발지 *= 목적지 (출발지는 레지스터여야 함)
IMUL과 IDIV
- IMUL은 특이하게 3번째 피연산자를 쓸 수 있다.
- IMUL EAX, EBX, 4h는 EAX = EBX * 0x4h로 수행됨
- IMUL은 하나의 연산자를 사용하는 경우도 있다.
- 이 경우 피연산자는 출발지를 의미함
- 출발지 피연산자의 크기에 따라 EAX의 각 다른 부분에 할당됨
출발지가 8비트라면?
AL로 입력받고 출력은 16비트(AH, AL)에 저장함
출발지가 16비트라면?
AX로 입력받고,출력은 32비트 (DX:AX)에 저장함 (AX의 0~15비트, DX의 16~31비트)
출발지가 32비트라면?
EAX로 입력받고, 출력은 64비트 (EDX, EAX)에 저장함(EAX의 0~31비트, EDX의 32~64비트)
- 하나의 레지스터가 입력된다고 해도 각 출력에 2개의 레지스터가 사용된다는 것을 유념해야 함
- 곱셈 연산은 입력값보다 출력값이 크기 때문
/*
32비트 피연산자를 사용
IMUL [BADF00Dh]
=> 의사코드로 바꾸면 아래와 같다.
EDX:EAX = EAX * [BADF00Dh]
*/
.model flat
public _TEST
.code
_TEST:
push ebp
mov ebp, esp
sub esp, 8
// 12 x 27 32bit imul
xor edx, edx
xor eax, eax
mov dword ptr[esp+4], 12
mov eax, 27
imul dword ptr[esp+4]
mov esp, ebp
pop ebp
ret
end
/*
16비트 피연산자 사용
IMUL CX
->의사코드로 바꾸면
DX:AX = AX * CX
*/
.model flat
public _TEST
.code
_TEST:
push ebp
mov ebp, esp
sub esp, 8
// 20 x 10 16bit imul
xor edx, edx
xor eax, eax
mov ax, 10
mov bx, 20
imul bx
or eax, edx
mov esp, ebp
pop ebp
ret
end
/*
8 비트 피연산자 사용
IMUL CL
->의사코드로 바꾸면
AX = AL * CL
*/
.model flat
public _TEST
.code
_TEST:
push ebp
mov ebp, esp
sub esp, 8
// 2 x 10 8bit imul
xor eax, eax
mov al, 10
mov bl, 2
imul bl
mov esp, ebp
pop ebp
ret
end
// 세 예제 공통으로 출력에 사용했던 C 파일
#include <stdio.h>
int TEST();
int main()
{
int res = TEST();
printf("res : %d", res);
}
- 결과는 모두 정상적으로 출력되었다.
IDIV에 대한 이야기
- 나눗셈을 위한 명령어도 당연히 존재함
- IMUL과 같은 레지스터 규칙을 준수한다.
- IDIV 연산은 2개의 입력과 2개의 출력으로 구성됨
출발지가 8비트라면?
AH:AL로 입력받고 나머지는 AH에, 몫은 AL에 저장함
출발지가 16비트라면?
DX:AX로 입력받고, 나머지는 DX에, 몫은 AX에 저장함
출발지가 32비트라면?
EDX:EAX로 입력받고, 나머지는 EDX, 몫은 EAX에 저장함
- 일반적으로 나머지는 첫번째 입력 레지스터에, 몫은 두번째 입력 레지스터에 저장
MOV EDX, 0
MOV EAX, inputValue (32비트 입력값)
IDIV ECX ( EDX:EAX를 ECX로 나눔)
// 의사코드로 바꾸면
EAX = EDX:EAX / ECX;
EDX = EDX:EAX % ECX;
- IDIV와 IMUL은 매우 중요하므로 정확히 숙지해야 함
브랜칭
- 표현식을 평가하면 이후 어떤 행동을 할 지 결정한다.
- 일반적으로 C언어에서는 if문이나 switch 구문을 사용함
- 그런데 어셈블리에선 그런 구문이 딱히 없음
- 그래서 어셈블리에서는 EFLAGS 레지스터를 사용해서 의사결정을 수행하고 점프 연산을 수행함
- 이런 프로세스를 '브랜칭'이라고 한다.
EFLAGS 레지스터로부터 값을 획득하기 위해선?
- TEST 혹은 CMP 인스트럭션 중 하나를 사용함
- 이 두 인스트럭션은 2개의 피연산자 값을 비교하고 EFLAGS 상태 비트 설정, 그리고 모든 결과를 제거함
- TEST - 논리적 AND를 사용해 두 값을 비교
- CMP - 부호가 있는 뺄셈을 통해 앞의 피연산자에서 뒤의 피연산자를 뺌
브랜칭을 수행하려면 비교 즉시 점프가 가능한 명령어가 있어야 함
- 점프 인스트럭션은 하나의 피연산자를 가지며, 그 피연산자는 이동할 주소임
- 점프 인스트럭션의 동작은 EFLAGS의 상태 비트에 따라 달라짐
CMP EAX, EBX
- CMP의 결과값이 0이면 ZF(Zero flag)의 값은 1, 아니면 0이 됨
CF (Carry Flag)
- 비트가 허용하는 자릿수를 넘어서서 캐리되는 경우 그 넘어선 자리의 비트가 캐리 비트의 값이 됨
1111
+ 0001
--------
10000
--------
0000
1 <- Carry Flag의 값
0001
- 1100
--------
10101
--------
0101
1 <- 빌림(Borrow)의 경우도 Carry Flag를 1로 세팅
OF (Overflow Flag)
0111 (7)
+ 0001 (1)
--------
1000 (-8)
- 부호 있는 정수의 덧셈의 경우 7 + 1을 했는데 -8이 됨
- 최상위 비트 (사인 비트)가 둘 다 0이었는데 결과값이 1이 됐을 때 오버플로우됐다고 여겨 세팅
SF (Sign Flag)
- 연산 결과값의 사인 비트의 값을 취함
0111 (7)
+ 0001 (1)
--------
1000 (-8)
- 이 경우 1로 세팅 (OF와는 별개)
점프 인스트럭션의 종류
인스트럭션 | 의미 | EFLAGS 관계 |
JMP | 무조건 점프 | |
JE | 동등하면 점프 | ZF가 1이면 점프한다 |
JNE | 동등하지 않으면 점프 | ZF가 0이면 점프한다 |
JG | 크면 점프 | ZF가 0이고, SF == OF면 점프한다 |
JGE | 크거나 같으면 점프 | SF == OF면 점프한다 |
JA | 부호 없는 JG | CF가 1이면 점프한다 |
JAE | 부호 없는 JGE | CF가 0이면 점프한다 |
JL | 작으면 점프 | SF != OF면 점프한다 |
JLE | 작거나 같으면 점프 | ZF가 1이거나 SF != OF면 점프한다 |
JB | 부호 없는 JL | CF가 1이면 점프한다 |
JBE | 부호 없는 JLE | CF가 1이거나 ZF가 1이면 점프한다 |
JO | 오버플로우 발생 시 점프 | OF가 1이면 점프한다 |
JNO | 오버플로우 미발생 시 점프 | OF가 0이면 점프한다 |
JZ | 결과 값이 0일 경우 점프 | ZF가 1이면 점프한다 |
JZE | 0이 아닐 경우 점프 | ZF가 0이면 점프한다 |
JG가 ZF가 0이고 SF == OF면 점프하는 이유는?
MOV EAX, 3
MOV EBX, 2
CMP EAX, EBX
이 경우를 살펴보면
0011
- 0010
--------
0001
CMP는 내부적으로 뺄셈을 한 결과를 취하게 되니 0001이 되고,
ZF = 0 (0이 아니므로)
SF = 0 (sign 비트가 0이므로)
OF = 0 (overflow가 발생하지 않았으므로)
ZF가 0이고, SF == OF가 만족하게 된다.
이번에는 반대로 빼보면
11122 (빌림)
0010 (2)
- 0011 (3)
--------
1111 (-1)
허용하는 비트 자리수 너머에서 빌림이 발생했으므로 OF가 1이 된다.
그리고 결과값이 마이너스이므로 SF도 1이 된다.
ZF = 0 (0이 아니므로)
SF = 1 (sign 비트가 1이므로)
OF = 1 (빌림이 발생했으므로)
따라서 ZF는 0이지만, SF != OF 이므로 JG에서 점프할 수 없게 된다.
- CMP가 선행하는 점프는 이에 대응하는 연산자와 동일한 동작을 함
// C언어 방식
if (EBX > EAX)
ECX = EDX;
else
ECX = 0;
// 어셈블리 방식
CMP EBX, EAX
label1:
MOV ECX, 0
JMP label2
lebel2:
MOV ECX, EDX
'게임 보안 > [서적] 봇을 이용한 게임 해킹' 카테고리의 다른 글
6. 코드 인젝션 (1) | 2022.10.01 |
---|---|
5. 게임 메모리 읽고 쓰기 (1) | 2022.09.29 |
4. 고급 메모리 포렌식 (0) | 2022.09.28 |
2. 코드에서 메모리로 - 기본 원리 (2) | 2022.09.24 |
1. 해킹 도구 (0) | 2022.09.18 |
댓글