Buffer Overflow

김민욱·2025년 5월 24일

"시스템 소프트웨어"라는 수업에서 buffer overflow라는 취약점에 대해 배웠다.
수업 내용과 과제 내용을 더해서 더 깊은 이해를 해보자.

buffer overflow를 이해하기 위해서는 먼저 메모리 구조에 대해 이해를 해야한다.

x86-64 Linux 메모리 구조

Stack

  • stack 자료구조로 관리되는 메모리 영역
  • local 변수 뿐만 아니라 함수 호출 시 리턴 주소, 프레임 포인터, 인자 등도 함께 저장된다.

Heap

  • 동적 할당되는 메모리 영역
  • malloc(), calloc(), new() 등
  • Heap은 낮은 주소에서 높은 주소로 커진다.

Data

  • 전역변수 및 static 변수들이 저장되는 데이터
  • 초기화 된 변수는 .data, 초기화 되지 않은 변수는 .bss 영역에 저장된다.

Text(Code) / Shared Libraries

  • 실행 가능한 기계어 (code)가 저장되는 Read-only 영역
  • .text 뿐만 아니라 .rodata도 포함된다.
  • Shared Libraries는 런타임 시 메모리에 매핑되어 코드와 함께 사용된다.

다음 예제 코드와 함께 메모리 영역을 살펴보자.

char big_array[1L<<24]; // 16MB
char huge_array[1L<<31]; // 2GB

int global = 0; // global var

int useless() { return 0; } // executable code

int main() // executable code
{
	void *p1, *p2, *p3, *p4;
    int local = 0; // local var
    /* dynamic allocate */
    p1 = malloc(1L << 28); // 256MB
    p2 = malloc(1L << 8); // 256B
    p3 = malloc(1L << 32); // 4GB
    p4 = malloc(1L << 8); // 256B
    
    /* ... */
}

stack : local
heap : p1, p2, p3, p4
bss : big_array, huge_array
data : global
text : main(), useless()

코드를 보면 위에서 설명했듯이 변수나 함수의 종류에 따라 메모리의 다른 위치로 저장되는 것을 알 수 있다.

우리가 오늘 집중적으로 다룰 곳은 바로 stack이다.
stack 메모리에 대한 자세한 설명은 하단의 링크를 참고하길 바란다.

stack segment와 procedure call

Stack-memory
Procedures-Calling-Conventions
Procedures-call-with-Stack-Frame

Memory Referencing Bug

다음 코드를 보자

typedef struct {
	int a[2];
    double d;
} struct_t;

double fun(int i)
{
	volatile struct_t s;
    s.d = 3.14;
    s.a[i] = 1073741824;
    
    return s.d;
}
; 함수 인자에 따른 출력의 차이
fun(0) -> 3.1400000000
fun(1) -> 3.1400000000
fun(2) -> 3.1399998655
fun(3) -> 2.0000006104
fun(4) -> 3.1400000000
fun(6) -> Segmentation fault

컴퓨터 공부를 해본 사람이라면 fun(2), fun(3) 등을 호출하면 값에 문제가 생김을 직감적으로 알 수 있을 것이다.
그러나 어째서 fun(4) 부터 갑자기 d의 값이 정상적으로 돌아오는 걸까?

c에서 구조체는 하나의 메모리 블록처럼 표현된다.

0         8        16
|   a[]   |    d    |
|---------|---------|

a[2]연산은 *(a+2)와 같은데, 이는 d의 메모리 영역을 침범한다. 따라서 a[2]a[3]에 값을 쓸 경우 d에 저장된 내용이 오염된다.
그러나 a[4] 부터는 d의 영역도 벗어나 d의 값을 오염시키지 않는다.
a[6]에 값을 썼을 때는 segmentation fault라는 오류가 발생하는데, 이는 접근해서는 안되는 메모리를 침범했다는 뜻이다.

이를 흔히 "Buffer overflow"라고 부른다.

Buffer Overflow

메모리 상의 버퍼에 너무 많은 데이터를 써서, 인접한 메모리 영역을 덮어 쓰는 현상

흔히 길이가 제한되지 않은 문자열 입력에서 일어난다.
이 현상이 위험한 이유는 보안 취약점을 유발하기 때문이다.

다음은 문자열 입력 중 일어날 수 있는 stack buffer overflow의 예시이다.

void echo()
{
	char buf[4];
    gets(buf);
    puts(buf);
}

void call_echo() { echo(); }
|-----------------------|
|      Stack Frame      |
|     for call_echo     |
|-----------------------|
|     Return Address    |
|-----------------------|
|                       |
|                       |
|    20 bytes unused    |
|                       |
|                       |
|-----------------------|
| [3] | [2] | [1] | [0] | buf
|-----|-----|-----|-----|

위 함수의 정상적인 메모리 사용은 위와 같다.
그러나 만약 string 입력이 버퍼 사이즈를 넘어간다면 다음과 같은 모양이 된다.

입력 : "01234567890123456789012"

|---------------------------|
|        Stack Frame        |
|       for call_echo       |
|---------------------------|
|  00  |  00  |  00  |  00  |
|  00  |  40  |  06  |  f6  | echo 호출 후 return address
|---------------------------|
|  00  |  32  |  31  |  30  |
|  39  |  38  |  37  |  36  |
|  35  |  34  |  33  |  32  |
|  31  |  30  |  39  |  38  |
|  37  |  36  |  35  |  34  |
|---------------------------|
|  33  |  32  |  31  |  30  | buf
|------|------|------|------|

여기서 Return Address 영역까지 침범한다면?

입력 : "0123456789012345678901234"

|---------------------------|
|        Stack Frame        |
|       for call_echo       |
|---------------------------|
|  00  |  00  |  00  |  00  |
|  00  |  40  |  00  |  34  | return address 오염
|---------------------------|
|  33  |  32  |  31  |  30  |
|  39  |  38  |  37  |  36  |
|  35  |  34  |  33  |  32  |
|  31  |  30  |  39  |  38  |
|  37  |  36  |  35  |  34  |
|---------------------------|
|  33  |  32  |  31  |  30  | buf
|------|------|------|------|

echo 함수를 수행한 후 엉뚱한 위치로 이동하게 된다.

이 점을 이용해 악의적인 이용자는 프로그램의 실행 흐름을 마음대로 조작할 수 있게 된다.
악성 코드를 삽입해 리턴 주소를 해당 위치로 조작하여 공격 코드를 실행시키는 공격도 가능하다.

그렇다면 우리는 이런 나쁜 짓을 어떻게 방지할 수 있을까?

방지

1. 코드 레벨
버퍼에 데이터를 입력할 때 입력 길이를 명시적으로 제한할 수 있는 방식을 사용하라.
❌ : gets(), scanf("%s", buf), strcpy()
✅ : fgets(), strncpy()

2. 시스템 레벨
ASLR : 메모리 영역 시작 주소 랜덤화
DEP / NX : code 영역을 제외한 영역에 대해 실행 권한 제거
PIE : 실행파일 자체를 메모리에 무작위로 배치

이를 통해 해커가 악성코드를 끼워넣거나 정확한 주소를 예측하는 것을 어렵게 만든다.

3. Stack Canaries
buffer와 return address 사이에 Canary 라는 특별한 값을 위치시켜 함수를 탈출할 때 이 값이 변하지 않았는지 검사한다.
Canary 값이 변경되었다면 프로그램을 즉시 종료한다.

이제는 안전할까?

새로운 코드를 삽입할 수 없다면 이제 어떻게 해야할까?
정상인의 사고방식이라면
"프로그램을 망가뜨리는 행동은 그만 포기하고 용도에 맞게 사용해야겠다!"
라는 결론이 나오겠지만 해커들은 그렇지 않다.
그들이 도달한 결론은
"악성 코드를 삽입할 수 없다면 프로그램 내부에 있는 코드들을 조합하여 악성 코드를 만들어야겠군!"이다.

이를 ROP(Return-Oriented Programming Attack)라고 한다.

ROP(Return-Oriented Programming Attack)

gadget
프로그램의 기존 코드에 존재하는 ret (0xc3)명령어로 끝나는 짧은 명령어 시퀀스.


어셈블리어가 위와 같이 인코딩 된다는 것은 다들 아는 사실이다.
이를 이용해 없던 코드도 만들어낼 수 있다.

이 코드는 어떠한 상수 값을 %eax 레지스터에 저장하는 코드이지만, 48 89 c7 c3만을 해석한다면

movq %rax, %rdi
ret

가 된다.

return address 위치에 0x40194c를 넣으면 이런 실행이 가능하다.

즉, ROP 공격은 프로그램의 기존 코드 안에서 0xc3(ret) 명령어로 끝나는 gadget들을 찾아, 이들의 주소를 buffer overflow를 통해 stack에 차례대로 삽입함으로써 공격자가 원하는 연산을 수행하게 만드는 공격 기법이다.


<참고자료>
Bryant and O’Hallaron, Computer Systems: A Programmer’s Perspective, Third Edition
안성용, "시스템소프트웨어", 부산대학교

0개의 댓글