"시스템 소프트웨어"라는 수업에서 buffer overflow라는 취약점에 대해 배웠다.
수업 내용과 과제 내용을 더해서 더 깊은 이해를 해보자.
buffer overflow를 이해하기 위해서는 먼저 메모리 구조에 대해 이해를 해야한다.

Stack
Heap
Data
.data, 초기화 되지 않은 변수는 .bss 영역에 저장된다.Text(Code) / Shared Libraries
.text 뿐만 아니라 .rodata도 포함된다.다음 예제 코드와 함께 메모리 영역을 살펴보자.
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
다음 코드를 보자
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"라고 부른다.
메모리 상의 버퍼에 너무 많은 데이터를 써서, 인접한 메모리 영역을 덮어 쓰는 현상
흔히 길이가 제한되지 않은 문자열 입력에서 일어난다.
이 현상이 위험한 이유는 보안 취약점을 유발하기 때문이다.
다음은 문자열 입력 중 일어날 수 있는 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)라고 한다.
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
안성용, "시스템소프트웨어", 부산대학교