SUA 시스템 해킹 스터디 - 어셈블리어, 메모리 구조, 함수

170728 1주차

SUA 운영진 활동 중 회원들과 같이 시스템 해킹 스터디를 진행하기로 했다. 시스템 해킹의 기초를 탄탄히 공부해야 나중에 Exploit 코드를 작성하고, Pwnable 문제를 푸는 데 어려움이 없기 때문이다.


어셈블리어 & CPU, 레지스터

일반적으로 CPU는 연산장치, 제어장치, 레지스터로 구성된다. 연산장치 는 말 그대로 수칙연산, 논리연산 등의 수학적 연산을 담당한다. 제어장치 는 메모리에서 기계어 코드를 읽고 해석한 뒤 실행하는 역할을 담당하며, 레지스터 는 연산을 위해 CPU가 사용하는 데이터 저장소이며 주로 메모리의 특정 주소나 특정 값을 저장한다.

인텔의 IA-32 구조에서 사용되는 레지스터 중 32bit 레지스터는 32비트(EAX), 세그먼트 레지스터는 16비트(AX)다.

AH, AL 레지스터는 각각 8비트이다.

IA-32 구조란?

레지스터는 범용 레지스터 8개, 명령 포인터, 세그먼트 레지스터, 플래그 레지스터 등으로 구성된다.

레지스터 설명
EAX 산술 계산 및 리턴 값 전달에 사용되며, 가장 자주 사용하는 레지스터다.
EBX 범용적으로 사용 가능한 추가적인 레지스터다.
ECX 일반적으로 반복문이나 문자열 복사 시 카운트에 사용한다.
EDX EAX와 비슷하게 주로 연산에 사용한다.
ESI 주로 문자열, 메모리 값을 복사할 때 원본 주소를 가리킨다.
EDI 주로 문자열, 메모리 값을 복사할 때 목표 주소를 가리킨다.
EBP 스택 프레임 시작 주소(Base)가 저장된다.
ESP 스택 프레임 끝 지점이 저장된다.
세그먼트 레지스터 각 세그먼트의 오프셋이 저장된다.
플래그 레지스터 연산의 결과에 따라 다양한 상태값을 표시한다.

예시

int sum(int a, int b)
{
  return a+b;
}

위 sum 함수는 두 개의 인자값을 받아서 더한 값을 리턴해 준다. 이 함수가 존재할 때, 계산값을 어딘가에 더하고 저장해 두어야 할 것이며, 레지스터는 이러한 역할을 수행하기 위해 존재한다. 이를 어셈블리어 코드로 바꾸면,

mov eax, dword ptr [a]

add eax dword ptr [b]

형식으로 나타나며, 함수 마지막 부분에 ret 코드가 실행되면 이 함수가 호출된 곳으로 돌아가는 것이다.

함수를 호출할 때는 call 코드를 사용하고, 반환값을 push 코드로 사용할 수 있다.


메모리 구조

윈도우의 메모리 구조는 32비트의 경우 기본적으로 프로세스 별로 4GB로 구성된다. 프로세스마다 4GB를 할당하기 위해 가상메모리를 사용하는데, 유저영역(User Mode) 2GB, 커널영역(Kernel Mode) 2GB로 총 4GB의 독립된 메모리 공간을 가지고 있다. 여기서 커널영역 2GB는 모든 프로세스가 공유한다.

메모리 가상화를 통해 프로그램은 자신이 모든 메모리를 소유한 것처럼 주소값을 신경쓰지 않고 사용할 수 있고, 오류가 발생하더라도 타 프로세스의 메모리와 격리되어 있으므로 안정성을 높일 수 있다. 각 가상메모리에 대한 물리 메모리 매핑과정은 윈도우 OS가 맡아서 한다.

그렇다면 프로세스에서 메모리는 실제로 프로그램에서 어떻게 사용될까?

스택은 후입선출의 구조를 가진 자료구조의 한 종류다. PUSH로 스택의 끝에 데이터를 넣고, POP으로 스택의 끝 데이터를 가져온다.

스택 메모리는 높은 주소에서 낮은 주소로, 힙 메모리는 낮은 주소에서 높은 주소로 할당된다.

여기서 ESP(스택 포인터), EBP 레지스터가 사용된다.

예시

int sum(int a, int b)
{
  int result = 0;
  result = a+b;
  return result;
}

int minus(int a, int b)
{
  int result = 0;
  result = a-b;
  return result;
}

void main(int argc, CHAR* argv[])
{
  int x = 9;
  int y = 4;
  printf("sum : %d \n", sum(x, y));
  printf("minus : %d \n", minus(x, y));
}

위 코드를 보면, sum 함수와 minus 함수 내부에서 계산값을 임시로 저장하기 위한 result 변수가 존재한다. 하지만 이 변수는 함수가 종료된 후에는 존재할 필요가 없는 변수이다. 이러한 변수들이 메모리에 할당되어 남아 있으면 메모리를 쓸데없이 차지하므로, 함수 내부에서만 사용되는 지역변수들이 주로 스택에 할당된다.

힙은 스택과 다르게 힙 관리자 및 힙 구조체를 통해 관리되며, 프로그래머가 필요 시 할당 및 해제를 할 수 있다. 힙은 주로 스택보다 큰 메모리가 필요할 때 사용하며, API를 통해 할당 받거나 반환할 수 있다.


함수 호출 & 리턴

각 함수는 호출된 후 필요한 만큼 메모리를 할당받아 사용하고, 함수가 종료된 후에는 메모리를 반환하고 종료한다. 함수마다 별도의 스택공간을 갖게 되고 이를 스택 프레임이라 부른다. 스택 프레임을 갖는 이유는 함수가 호출되기 전과 후에 스택에 변경이 없어야만 하기 때문이다. 그렇지 않으면 함수가 호출된 후 메모리값이 엉망이 되어 에러가 발생할 수 있다.

정리하면, 함수 호출 시 스택에 돌아올 주소를 저장하고, 스택 프레임을 생성한다. 그리고 함수 종료 시 스택 프레임을 제거하고 호출한 주소로 복귀한다! 이렇게 구분하면 함수 내부에서 ESP의 변경과 상관없이 EBP 레지스터를 이용해 인자값에 접근할 수 있다.

함수의 시작 부분에서 스택 프레임을 만들어주는 코드를 함수 프롤로그라 부르며, 스택 프레임을 해제하고 돌아가는 코드를 함수 에필로그라 부른다.

PUSH EBP // 스택 프레임 생성(call), 이전 스택 프레임의 EBP 저장

MOV EBP, ESP // 현재 스택의 끝 주소(ESP)를 EBP 레지스터에 저장

MOV ESP, EBP // 스택 프레임 제거(return), 사용 중이던 ESP를 EBP 레지스터에 저장된 스택 프레임 시작주소로 복구

POP EBP // ESP가 가리키는 주소에 저장된 이전 EBP 주소를 꺼내서 EBP 레지스터에 저장

RET // POP EIP와 동일, 스택에 저장된 복귀 주소를 꺼내서 EIP에 저장

인자값을 전달하고 스택을 정리할 때 함수를 호출하는 쪽과 호출당하는 함수 사이 혼란을 방지하기 위해 일정한 규약이 존재한다. 이를 함수 호출 규약(Calling Convention) 이라 부른다.

_cdecl : 인자값 전달은 오른쪽부터, 스택 정리는 caller(Add esp, n)

_stdcall : 인자값 전달은 오른쪽부터, 스택 정리는 callee(ret n)

_fastcall : 인자값 전달은 레지스터 + 스택, 속도가 빠르나 경우에 따라 오히려 코드가 길어짐


어셈블리어 기초

PUSH : 스택에 값을 저장한다. PUSH 후 스택이 4바이트 커지기 때문에 ESP 레지스터는 4바이트 감소한다. 스택은 높은 주소에서 낮은 주소로 자라기 때문이다.

ex) PUSH EBP // EBP의 값을 스택에 PUSH

POP : PUSH와 반대로 스택의 끝에 저장된 값을 가져온다. POP 후에는 스택이 4바이트 줄어들기 때문에 ESP 레지스터가 4바이트 증가한다.

ex) POP EBP // 스택의 끝의 값을 꺼내서 EBP에 저장

MOV : 지정한 값을 지정한 곳에 넣어준다. 바이트에 따라 다양한 파생 명령어들이 있다.

ex) MOV ECX, 4 // 4를 ECX에 저장

LEA : MOV와 거의 동일한 역할을 수행하지만, MOV는 주소에 저장된 값을 저장하고, LEA는 주소를 그대로 저장한다.

ex) LEA EAX, [EBP+10] // EBP+10에 저장된 주소를 EAX에 저장

INC, DEC : INC는 값을 1 증가시키고, DEC는 값을 1 감소시킨다.

ADD : 두 오퍼랜드의 덧셈 연산을 수행한다.

ex) ADD EAX, 3A // EAX에 3A를 더한 뒤 EAX에 저장

SUB : 두 오퍼랜드의 뺄셈 연산을 수행한다.

ex) SUB ESP, 10 // ESP에서 10을 뺀뒤 ESP에 저장

CALL : 함수를 호출하는 명령어로, 스택에 리턴 주소를 PUSH한 뒤 바로 뒤의 주소값으로 점프한다.

ex) CALL 주소값 // 주소값 함수 실행

RET : 함수 내부에서 다시 원래 코드로 돌아오는 명령어로, CALL 명령 수행 시 스택에 저장했던 복귀주소로 돌아온다.
NOP : 아무 동작도 하지 않는 코드, 0x90

XOR : 각 비트를 비교하여 값이 같으면 0, 다르면 1로 계산
OR : 두 비트 중 하나라도 1이면 1, 아니면 0으로 계산
AND : 두 비트 모두 1이면 1, 나머지 경우는 0으로 계산
SHR : 각 비트를 오른쪽으로 쉬프트 연산한다. 벗어난 비트는 CF플래그 레지스터에 저장된다.
SHL : 각 비트를 왼쪽으로 쉬프트 연산한다. 벗어난 비트는 CF플래그 레지스터에 저장된다.

아래 명령어들은 IF, FOR 문 등의 제어 구문을 구현할 때 많이 사용된다.

JMP : 무조건 점프
JE : 비교 결과가 같으면 점프
JGE : 비교 결과가 크거나 같으면 점프
JLE : 비교 결과가 작거나 같으면 점프
JNE : 비교 결과가 같지 않으면 점프
JZ : 0이면 점프(제로 플래그가 1이면 점프)


[질문 내용 정리]

리틀 엔디언은 연산할 때 작은 자리부터 하는데 뒤에서 부터 계산할 때가 더 빠르기에 사용, 빅 엔디언은 사람이 봤을 때 또는 읽을 때 크기를 보기 직관적이어서 사용한다.

MOV 할 때 예를 들어, MOV DWORD PTR SS:[esp] 문자열에서 ESP가 가리키는 공간에 값을 넣어주는데, DWORD PTR(포인터) 크기가 4바이트인 것과 상관없이 문자열이 있는 주소를 넣은 것이라 크기랑 상관 없음.. LEA는 연산할 때 쓰지만 MOV는 그렇지 않다. MOV 를 했을 때 문자열이 있는 주소값이 숫자 형태로 읽어지는 것은 즉, 숫자 형태로 다시 쓸 수 있는 건가? 평범한 레지스터를 MOV 해도 숫자로 바뀌는데..

DWORD 가 더블 워드로 32비트에서 4바이트를 의미한다. 세그먼트 레지스터는 메모리 영억을 세그먼트 단위로 나누고, 해당 영역의 시작 주소를 저장할 때 사용하며 SS는 스택 세그먼트, DS는 데이터 세그먼트다. 참고로 WORD는 2바이트(16bit), byte는 1바이트(8bit)를 말한다.