안녕하세요. IT 엘도라도 에 오신 것을 환영합니다.
글을 쓰는 것은 귀찮지만 다시 찾아보는 것은 더 귀찮습니다.
완전한 나만의 것으로 만들기 위해 지식을 차곡차곡 저장해 보아요.   포스팅 둘러보기 ▼

컴퓨터 구조 (Architecture)/CSAPP

[CSAPP] x86-64 - Miscellaneous Topics

피그브라더 2020. 3. 3. 22:10

1. 메모리 레이아웃

1-1. x86-64 메모리 레이아웃 (Memory Layout)

메모리 레이아웃(Memory Layout)이란 프로그램이 실행될 때 해당 프로그램의 데이터와 코드가 어느 곳에 어떻게 할당되는지를 나타내는 도식이다. 엄밀하게는 프로그램의 데이터와 코드가 로드되는 가상 주소 공간(Virtual Address Space)의 구조를 나타낸다. 물론 이는 시스템마다 다르다. 예를 들어 x86-64에서 컴파일되는 프로그램을 실행하면 해당 프로그램의 데이터와 코드가 오른쪽 그림과 같이 가상 주소 공간에 로드된다.

 

1-1-1. 스택 (Stack)

다른 말로 런타임 스택이라고도 하며, 8MB의 공간 제한이 있다. 지역 변수 등의 함수 내 지역 데이터가 저장되는 영역이다.

 

1-1-2. 힙 (Heap)

동적으로 할당되는 데이터들이 위치하는 영역이다. C 언어의 malloc(), calloc() 함수 등이 호출될 때 사용된다.

 

1-1-3. 데이터 (Data)

정적으로 할당되는 데이터들이 위치하는 영역이다. 전역 변수와 static 변수, 그리고 문자열 등의 상수가 이곳에 저장된다. 

 

1-1-4. 텍스트 (Text), 공유 라이브러리 (Shared Libraries)

프로그램 코드에 해당하는 명령어들이 순차적으로 저장되는 읽기 전용(Read-only) 영역이다. 프로그램 실행 파일의 코드는 텍스트 영역, 공유 라이브러리의 코드는 별도의 공간에 로드된다.


1-2. 데이터/코드 할당 예시

이해를 돕기 위해 다음 예시를 살펴보자. 전역 변수에 해당하는 big_array, huge_array, global은 데이터 영역에 로드되고, 지역 변수에 해당하는 local은 스택 영역에 로드된다. 그리고 malloc으로 동적 할당된 데이터들(각각 p1, p2, p3, p4가 가리킴)은 힙 영역에 로드가 된다. 마지막으로 함수에 해당하는 useless()와 main()의 코드는 텍스트 영역에 로드가 된다.

 

 

2. 버퍼 오버플로우

2-1. 버퍼 오버플로우 (Buffer Overflow)

버퍼 오버플로우(Buffer Overflow)는 메모리를 대상으로 하는 대표적인 시스템 해킹 기법 중 하나로, 메모리에 할당된 공간을 의도와 다르게 접근하여 조작하는 것을 말한다. 이해를 돕기 위해 간단한 예시를 살펴보자. 오른쪽 그림은 현재의 스택 프레임에 struct_t 타입의 구조체 지역 변수가 할당되어 있는 구조를 보여준다. 원칙대로라면 구조체 멤버에 해당하는 배열 a는 길이가 2이므로 a[0]과 a[1]만 올바른 접근이라고 할 수 있다. 하지만 컴파일러는 a가 int 형 데이터로 이뤄진 배열의 첫 번째 요소를 가리키는 포인터라는 것만 알고, 그 배열의 길이는 따로 기억하지 않는다. 따라서 a[2], a[3]도 정상적으로 컴파일이 되고 실행으로까지 이어진다. 이러한 경우 3.14에 해당하는 값을 d 자리에 올바르게 저장했는데도 불구하고 배열 a에 대한 잘못된 접근으로 인해 d 자리에 잘못된 값이 쓰이게 된다. 이처럼 허용된 공간을 초과하여 메모리에 접근함으로써 메모리를 조작하는 것이 버퍼 오버플로우의 대표적인 사례이다.

 


2-2. 버퍼 오버플로우 발생 이유

그렇다면 메모리 보안을 위협하는 버퍼 오버플로우 공격이 이뤄질 수 있는 이유는 무엇일까? 앞서 간단히 언급했지만, 가장 큰 이유는 배열의 길이 정보를 컴파일러가 기억하지 않기 때문이다. 따라서 특정 배열에 일련의 값들 혹은 문자열을 저장하는 경우 그 길이를 검사할 수 없는 것이다. 이 때문에 해당 배열의 길이를 넘는 데이터를 의도적으로 삽입하여 해커의 의도대로 메모리를 조작할 수 있게 된다.


2-3. 버퍼오버플로우 예시

그렇다면 앞서 든 예시보다 조금 더 실제 버퍼 오버플로우 공격에 가까운 예시를 하나 살펴보자. 우선 다음은 C 언어의 내장 함수인 gets()의 내부 구현을 보여준다. dest는 입력받을 문자열이 저장될 공간의 주소를 나타내는데, gets() 입장에서는 그 공간의 크기가 얼마나 되는지 알 방법이 없기 때문에 파일의 끝이나 개행 문자를 만날 때까지 문자 하나를 읽고 저장하기를 반복한다. 이는 또 다른 C 언어 내장 함수인 strcpy(), strcat(), scanf(), fscanf(), sscanf() 등에서도 발생하는 문제이다.

 

 

이제 이러한 get() 함수의 특성을 이용하여 버퍼 오버플로우 공격을 행하는 경우를 살펴보자. call_echo() 함수가 echo() 함수를 호출하면, 키보드로부터 문자열을 입력받아 그것을 4바이트만큼 할당된 배열 buf에 저장하게 된다. 실행 결과에서도 볼 수 있듯이 4바이트 이상의 문자열을 입력하여도 문제없이 동작하는 것을 볼 수 있다. 다만 심각한 수준의 메모리 침범은 내부적으로 탐지하는 방법이 있어서(설명 생략), 이러한 경우 Segmentation Fault 예외가 발생하게 된다.

 

 

위의 코드를 어셈블리어로 번역한 결과와 메모리 할당 구조는 다음과 같다. echo() 함수의 스택 프레임에는 4바이트 길이의 buf 배열과 20바이트만큼의 사용하지 않는 빈 공간이 할당된다. 따라서 24바이트 길이의 문자열까지는 입력 시 별다른 문제가 발생하지 않는다. 그렇다면 이제 gets() 함수가 호출되어 키보드로부터 특정 길이의 문자열을 입력받은 뒤의 상황을 대략 세 가지 경우로 나눠서 살펴보자.

 

 

2-3-1. 문자열의 길이 ≤ 24바이트

첫 번째 경우는 24바이트보다 작거나 같은 길이의 문자열이 입력된 경우이다. 이러한 경우 echo() 함수의 복귀 주소(Return Address)를 손상하지 않기 때문에 별다른 문제없이 프로그램이 정상 동작한다.

 

 

2-3-2. 문자열의 길이 > 24바이트 (① Segmentation Fault 발생 O)

두 번째는 비정상적으로 긴 문자열이 입력되어 복귀 주소가 완전히 훼손되고, Segmentation Fault 예외가 발생하는 경우이다.

 

 

2-3-3. 문자열의 길이 > 24바이트 (② Segmentation 발생 X)

마지막은 24바이트보다 긴 문자열이 입력됨에도 불구하고 Segmentation Fault가 발생하지 않고 복귀 주소를 일부 망가뜨리는 경우이다. 이러한 경우 echo() 함수의 복귀 주소가 원래의 값을 잃어버리므로 예상치 못한 코드로 리턴하게 된다.

 


2-4. 코드 인젝션 (Code Injection)

위에서 기술한 버퍼 오버플로우 공격의 대표적인 사례가 바로 코드 인젝션(Code Injection)이다. 다음 예를 보자. P() 함수가 Q() 함수를 호출하면 키보드로부터 문자열을 입력받아 64바이트 길이의 배열 buf에 저장하게 된다. 다음 그림에서 A로 표시된 부분은 Q()가 P()로 돌아가기 위한 복귀 주소인데, 버퍼 오버플로우 공격을 통해 그 값을 B로 바꿔버릴 수 있다. 그리고 B는 악의적인 목적으로 입력된 문자열이 저장되는 공간에 위치하는 코드들의 시작 주소가 되게 하는 것이다. 그러면 Q()는 P()로 올바르게 리턴하지 못하고 해커가 의도한 코드로 리턴함으로써 잘못된 코드가 실행이 되는 것이다.

 

 

3. 공용체, 바이트 오더링

3-1. 공용체 (Union)

C 언어의 공용체(Union)은 구조체(Structure)와 여러 모로 비교가 많이 되는 자료구조이다. 구조체는 멤버 변수들이 메모리 상에 선언된 순서대로 할당이 되지만, 공용체는 하나의 공간을 여러 변수가 공유하여 사용한다.

 

 

그렇다면 공용체 내 멤버 변수는 어떻게 접근이 이뤄질까? 공용체의 가장 큰 장점은 하나의 공간을 여러 자료형의 관점에서 사용할 수 있다는 것이다. 예를 들어 "int a"로 선언된 a라는 공간에는 2의 보수로 표현되는 정수 값만 저장될 수 있다. 만약 다른 자료형의 값을 넣으려고 하면 int 형으로 자동 형 변환을 수행하거나, 에러가 발생하게 된다. 그러나 공용체는 그렇지 않다. 위의 예시와 같이 선언된 공용체가 있다고 가정할 때, c에 접근하면 문자를 저장할 수 있고 v에 저장하면 실수를 저장할 수 있다.

 

다음 예를 살펴보자. 이를 이해할 수 있다면 위에서 기술한 개념을 전부 이해했다고 보아도 무방하다. bit2float() 함수의 경우, 매개변수로 전달되는 정수 값을 공용체에 저장한 뒤 그 값을 float 형으로 반환한다. 즉 비트 상에는 u에 해당하는 정수 값이 2의 보수로 표현되어 있을 텐데, 그 비트 배열은 그대로 둔 채 해석만 float 방식으로 하겠다는 것을 의미한다. 이는 명백하게 "(float) u"와 다르다. 이는 u에 해당하는 값을 똑같이 표현할 수 있는 실수 값을 나타내도록 비트의 배열을 완전히 바꿀 것이기 때문이다. float2bit() 함수도 마찬가지로 해석하면 된다.

 


3-2. 바이트 오더링 (Byte Ordering)

바이트 오더링(Byte Ordering)이란 메모리에 연속적으로 할당되는 데이터들의 비트 배열 방향을 나타낸다. 주소가 가장 작은 부분이 MSB이면 Big Endian 방식, 주소가 가장 큰 부분이 MSB이면 Little Endian 방식이라고 한다. (사람이 읽기 편한 방식은 Big Endian이라고 할 수 있다. 보통 비트 배열은 왼쪽에서 오른쪽으로 쓰고, 주소도 오른쪽으로 증가한다고 보는 게 편하기 때문이다.) 바이트 오더링은 기계들끼리 이진 데이터를 주고받기 위해 통일되어야 하는 특성으로서, 시스템마다 다르다. x86-64의 경우 Little Endian 방식을 채택하고 있다. 참고로 두 방식을 혼합하여 사용하는 Bi Endian이라는 방식도 존재한다.

 

다음은 한 공용체 내에 선언된 char 형 배열, short  배열, int  배열, long  배열을 통해 시스템의 바이트 오더링이 무엇인지 파악하기 위한 코드를 나타낸다. 직접 노트를 펴고 메모리 구조도를 그려보면서 다음 코드를 꼭 이해하고 넘어가기 바란다. 이를 이해하면 바이트 오더링은 대부분 이해한 것이다. (참고로 이 셋 중 x86-64만 64비트, 나머지는 32비트 ISA에 해당한다.)

 

'컴퓨터 구조 (Architecture) > CSAPP' 카테고리의 다른 글

[CSAPP] Y86-64 - Logic Design  (0) 2020.03.07
[CSAPP] Y86-64 - Basics  (2) 2020.03.05
[CSAPP] x86-64 - Data  (0) 2020.03.02
[CSAPP] x86-64 - Procedures  (2) 2020.03.01
[CSAPP] x86-64 - Control  (7) 2020.02.29