본문 바로가기

SYSTEM HACKING

[SWING] Pwnable 01 - 메모리 구조

 

Ubuntu 설치

 

저번 시스템 해킹 동계 방학 스터디에서 드림핵 커리큘럼을 보고 설치해둔 것이 있길래,

18.04지만 그대로 사용할까 한다.

 

리눅스 환경 구축

Ubuntu Linux 18.04 설치 (아래 링크 참고)

https://releases.ubuntu.com/18.04/

https://antdev.tistory.com/46

https://syhwang.tistory.com/42

 

 

 

메모리 구조 (Memory Layout)

 

운영체제는 다양한 메모리 공간을 제공한다. 프로그램이 실행되기 위해서는 먼저 프로그램이 메모리에 로드되어야 하고, 프로그램에서 사용되는 변수들을 저장할 메모리도 필요하기 때문이다.

 

리눅스는 프로세스의 메모리를 데이터의 용도별로 메모리의 구획을 나눈 세그먼트(Segment)로 구분한다.

용도에 맞게 읽기, 쓰기, 실행 권한을 부여하면 CPU가 메모리에게 행위를 할 수 있다.

 

1. 코드 세그먼트

2. 데이터 세그먼트

3. BSS 세그먼트

4. 스택 세그먼트

5. 힙 세그먼트

 

 

 

 

 

 

 

코드 세그먼트 (Code Segment)

실행할 프로그램의 코드가 저장되는 영역으로, 텍스트 세그먼트(Text segment)라고도 부른다.

CPU는 코드 세그먼트에 저장된 명령어를 하나씩 가져가서 처리한다.

읽기 권한과 실행 권한이 부여되나, 공격자가 악의적인 코드를 삽입하는 것을 막기 위해 쓰기 권한은 부여하지 않는다.

 

int main() { return 31337; }

 

정수 31337을 반환하는 main 함수가 컴파일 -> 554889e5b8697a00005dc3 기계 코드로 변환 -> 코드 세그먼트에 위

 

 

데이터 세그먼트 (Data Segment)

컴파일 시점에 값이 정해진 전역(global) 변수와 정적(static) 변수가 저장되는 영역이다.

프로그램의 시작과 함께 할당되며, 프로그램이 종료되면 소멸한다.

읽기 권한이 부여된다.

 

데이터 세그먼트는 아래 표와 같이 구분된다.

 

구분 data segment rodata (read-only data)
쓰기 가능 여부 쓰기 가능 쓰기 불가능
위치한 데이터의 가변/불변 여부 실행되면서 값이 변할 수 있는 데이터 실행되면서 값이 변하면 안되는 데이터
예시 전역 변수 전역 상수

 

데이터 세그먼트의 코드

 

int data_num = 31337;                       // data
char data_rwstr[] = "writable_data";        // data
const char data_rostr[] = "readonly_data";  // rodata
char *str_ptr = "readonly";  // str_ptr은 data, 문자열은 rodata
int main() { ... }

 

str_ptr 이 가르키는 "readonly" 상수 문자열 취급 -> rodata에 위치

str_ptr 전역 변수 ->data에 위치

 

BSS 세그먼트 (Block Started by Symbol Segment)

컴파일 시점에 값이 정해지지 않은 전역 변수가 위치한다. (ex. 개발자가 선언하고 초기화는 하지 않은 전역 변수)

프로그램이 시작될 때 메모리 영역의 값은 모두 0으로 초기화된다.

이때문에 C 코드를 작성할 때 초기화되지 않은 전역 변수의 값은 모두 0이 된다.

읽기 권한과 쓰기 권한이 부여된다.

 

BSS 세그먼트의 코드

 

int bss_data;
int main() {
  printf("%d\n", bss_data);  // 0
  return 0;
}

 

초기화되지 않은 전역 변수 bss_data -> bss 세그먼트에 위치

 

스택 세그먼트 (Stack Segment)

프로그램 실행 중 생성되는 임시 데이터들이 저장되는 임시 메모리 영역이다. (ex. 함수의 인자, 함수의 호출과 관계되는 지역 변수, 매개변수 등)

함수의 호출 정보이자 단위를 스택 프레임(stack frame)이라고 한다.

함수의 호출과 함께 할당되며, 함수의 호출이 완료되면 소멸한다.

읽기 권한과 쓰기 권한이 부여된다.

 

동적으로 할당되어서 크기를 예상할 수 없다.

프로세스를 시작할 때 작은 크기를 할당받고 부족하면 확장한다.

확장될 때 기본 주소보다 낮은 주소로 확장 즉, 높은 주소에서 낮은 주소의 방향으로 할당되는 리틀 앤디언 방식으로 저장 -> 스택은 아래로 자란다!

애초에 스택이라는 단어가 무언가를 쌓는다는 의미이기 때문에 아래에서 위로 올라오는 방향이라고 이해하면 쉽다.

 

푸시(push) 동작으로 데이터를 저장하고, 팝(pop) 동작으로 데이터를 인출한다.

후입선출(LIFO, Last-In First-Out) 방식에 따라 동작하므로, 가장 늦게 저장된 데이터가 가장 먼저 인출된다.

 

스택 세그먼트의 코드

 

void func() {
  int choice = 0;
  scanf("%d", &choice);
  if (choice)
    call_true();
  else
    call_false();
  return 0;
}

 

지역변수 choice -> 스택에 저장

 

힙 세그먼트 (Heap Segment)

사용자가 직접 관리할 수 있고 관리해야만 하는 메모리 영역이다.

동적으로 할당되고 해제된다.

확장될 때 기본 주소보다 높은 주소로 확장 즉, 낮은 주소에서 높은 주소의 방향으로 할당되는 빅 앤디언 방식으로 저장 -> 힙은 위로 자란다!

 

힙 세그먼트의 코드

 

int main() {
  int *heap_data_ptr =
      malloc(sizeof(*heap_data_ptr));  // 동적 할당한 힙 영역의 주소를 가리킴
  *heap_data_ptr = 31337;              // 힙 영역에 값을 씀
  printf("%d\n", *heap_data_ptr);  // 힙 영역의 값을 사용함
  return 0;
}

 

heap_data_ptr에 malloc()으로 동적 할당한 영역의 주소를 대입하고 이 영역에 값을 씀 -> heap_data_ptr 지역변수라 스택에 위치 -> malloc()으로 할당받은 힙 세그먼트의 주소 가리킴

 

 

위의 HEAP과 STACK영역은 사실 같은 공간을 공유한다.

HEAP이 메모리 위쪽 주소부터 할당되면 STACK은 아래쪽부터 할당되는 식이다.

그래서 각 영역이 상대 공간을 침범하는 일이 발생할 수 있는데,

이를 각각 HEAP OVERFLOW, STACK OVERFLOW라고 칭한다.

Stack 영역이 클수록 Heap 영역이 작아지고,

Heap 영역이 클수록 Stack 영역이 작아진다.

 

 

세그먼트 역할 일반적인 권한 사용 예
코드 세그먼트 실행 가능한 코드가 저장된 영역 읽기, 실행 main() 등의 함수 코드
데이터 세그먼트 초기화된 전역 변수 또는 상수가 위치하는 영역 읽기와 쓰기 또는 읽기 전용 초기화된 전역 변수, 전역 상수
BSS 세그먼트 초기화되지 않은 데이터가 위치하는 영역 읽기, 쓰기 초기화되지 않은 전역 변수
스택 세그먼트 임시 변수가 저장되는 영역 읽기, 쓰기 지역 변수, 함수의 인자 등
힙 세그먼트 실행중에 동적으로 사용되는 영역 읽기, 쓰기 malloc(), calloc() 등으로 할당 받은 메모리

 

 

스택 프레임 구조

 

ESP(스택 포인터)가 아닌 EBP(베이스 포인터) 레지스터를 사용하여

스택 내의 로컬 변수, 파라미터, 복귀 주소에 접근하는 기법을 말한다.

 

ESP레지스터의 값은 프로그램 안에서 수시로 변경되기 때문에

스택에 저장된 변수, 파라미터에 접근하고자 할 때 EPS 값을 기준으로 하면

프로그램을 만들기 힘들고, CPU가 정확한 위치를 참고할 때 어려움이 있다. 

 

따라서 어떤 기준 시점(함수 시작)의 ESP값을 EBP에 저장하고 이를 함수 내에서 유지해주면,

ESP 값이 아무리 변하더라도 EBP를 기준으로 안전하게 해당 함수의 변수, 파라미터, 복귀 주소에 접근할 수 있다.

 

PUSH EBP 		; 함수 시작(EBP를 사용하기 전에 기존의 값을 스택에 저장) 
MOV EBP, ESP		; 현재의 ESP(스택 포인터)를 EBP에 저장

. . .			; 함수 본체
			; 여기서 ESP가 변경되더라도 EBP가 변경되지 않으므로 
         	  	; 안전하게 로컬변수와 파라미터를 액세스할 수 있음 
                
MOV ESP, EBP		; ESP를 정리(함수 시작했을 떄의 값으로 복원시킴)
POP EBP			; 리턴되기 전에 저장해 놓았던 원래 EBP 값으로 복원 
RETN			; 함수 종료

 

 

스택 프레임을 이용해서 함수 호출을 관리하면,

아무리 함수 호출 depth가 깊고 복잡해져도 스택을 완벽하게 관리할 수 있다. 

 

참고)

최신 컴파일러는 최적화 옵션을 가지고 있어서 간단한 함수 같은 경우 스택 프레임을 생성하지 않는다.
스택에 복귀 주소가 저장된다는 점이 보안 취약점으로 작용할 수 있다.

buffuer overflow 기법을 사용하여 복귀 주소가 저장된 스택 메로리를 의도적으로 다른 값으로 변경할 수 있다. 

 

아래 코드로 스택 프레임의 동작 방식을 알 수 있다.

 

int main(void)
{
    func1();  // func1() 호출
    return 0;
}

void func1()
{
    func2();  // func2() 호출
}

void func2()
{
}

 

 

함수 호출에 의한 스택 프레임의 변화

 

Step 1. 프로그램이 실행되면, 가장 먼저 main() 함수가 호출되어 main() 함수의 스택 프레임이 스택에 저장된다.
Step 2. func1() 함수를 호출하면 해당 함수의 매개변수, 반환 주소값, 지역 변수 등의 스택 프레임이 스택에 저장된다.
Step 3. func2() 함수를 호출하면 해당 함수의 스택 프레임이 추가로 스택에 저장된다.
Step 4. func2() 함수의 모든 작업이 완료되어 반환되면, func2() 함수의 스택 프레임만이 스택에서 제거된다.
Step 5. func1() 함수의 호출이 종료되면, func1() 함수의 스택 프레임이 스택에서 제거된다.
Step 6. main() 함수의 모든 작업이 완료되면, main() 함수의 스택 프레임이 스택에서 제거되면서 프로그램이 종료된다.

 

더하여 이전에 Stackframe.exe 실습 과정을 문서화해둔 링크를 보충을 위해 첨부한다.

 

https://coderrim.tistory.com/16

 

Reversing 02

과제 1 stackframe.exe 다운받기 >> book/StackFrame.exe at master · reversecore/book · GitHub #include "stdio.h" long add(long a, long b) { long x=a, y=b; return (x+y); } int main(int argc, char* argv[]) { long a=1, b=2; printf("%d\n", add(a, b)); re

coderrim.tistory.com

 

 

레지스터 종류

 

 

Register는 CPU 내부에 존재하는 작고 고속인 다목적 저장 공간이다.

 

  이름 크기 (bit) 용도
범용 레지스터 EAX 32 주로 산술 연산/함수 결괏값 저장
EBX 32 특정 주소 저장 (함수 호출 시 ESP 값)
ECX 32 반복 횟수 저장
EDX 32 일반 자료 저장
세그먼트 레지스터 CS 16 코드 세그먼트
SS 16 스택 세그먼트
DS 16 데이터 세그먼트
프로그램 상태와 컨트롤 레지스터 EFLAGS 32 ZF, OF, CF 등 상태 표시
Instruction Pointer EIP 32 CPU가 처리할 명령어의 주소를 나타냄

 

범용 레지스터(General Register)

주로 논리, 수리 연산이나 주소 계산에 사용되는 피연산자이나, 그 외의 용도로도 자유롭게 사용할 수 있는 레지스터이다.

8바이트를 저장할 수 있고 메모리 포인터가 저장된다.

x64 아키텍처에는 rax, rbx, rcx, rdx, rsi, rdi, rsp, rbp, r8-r15가 있다. 


이름 주용도
rax (accumulator register) 함수의 반환 값
rbx (base register) x64에서는 주된 용도 없음
rcx (counter register) 반복문의 반복 횟수, 각종 연산의 시행 횟수
rdx (data register) x64에서는 주된 용도 없음
rsi (source index) 데이터를 옮길 때 원본을 가리키는 포인터
rdi (destination index) 데이터를 옮길 때 목적지를 가리키는 포인터
rsp (stack pointer) 사용중인 스택의 위치를 가리키는 포인터
rbp (stack base pointer) 스택의 바닥을 가리키는 포인터

 

세그먼트 레지스터(Segment Register)

 

코드 세그먼트, 데이터 세그먼트, 스택 세그먼트를 가리키는 주소가 들어있다.

과거에는 메모리 세그멘테이션이나, 가용 메모리 공간의 확장을 위해 사용됐으나,

현재는 주로 메모리 보호를 위해 사용되는 레지스터이다.

x64 아키텍처에는 cs, ss, ds, es, fs, gs가 있다.

 

 

플래그 레지스터(Flag Register)

 

CPU의 상태를 저장하는 레지스터이다.

프로그램의 현재 상태나 조건 등을 검사하는데 사용되는 플래그들이 들어있다.

x64 아키텍처에는 각 크키가 16비트인 cs, ss, ds, es, fs, gs 6가지 세그먼트 레지스터가 존재한다.

cs, ds, ss 레지스터는 코드 영역과 데이터, 스택 메모리 영역을 가리킬 때 사용하고, 나머지 레지스터는 운영체제 별로 용도를 결정한다.

 

플래그 의미
CF(Carry Flag) 부호 없는 수의 연산 결과가 비트의 범위를 넘을 경우 설정 됩니다.
ZF(Zero Flag) 연산의 결과가 0일 경우 설정 됩니다.
SF(Sign Flag) 연산의 결과가 음수일 경우 설정 됩니다.
OF(Overflow Flag) 부호 있는 수의 연산 결과가 비트 범위를 넘을 경우 설정 됩니다.

 

 

명령어 포인터 레지스터(Instruction Pointer Register, IP)

CPU가 실행해야할 코드를 가리키는 레지스터이다.

다음에 수행해야 하는 명령이 있는 메모리 상의 주소가 들어있다.

x64 아키텍처에서는 8바이트 크기의 rip가 있다.