1. 컵에 물을 계속 부으면?
8비트짜리 게임기 팩을 꽂아서 게임을 하던 시절, 특정 레벨에서 이상한 행동을 하면 갑자기 엔딩 화면이 나오거나 무적 모드가 되는 버그(Glitch)들이 있었습니다. 이들 중 상당수는 메모리 관리를 잘못해서 생긴 일이었습니다.
버퍼(Buffer)는 데이터를 임시로 담아두는 그릇(메모리 공간)입니다. 오버플로우(Overflow)는 넘친다는 뜻입니다.
100ml 컵에 200ml 물을 부으면 어떻게 될까요? 물은 식탁으로 흘러넘쳐, 옆에 있던 스마트폰을 적시고 고장 낼 수도 있습니다. 컴퓨터에서도 똑같습니다. "이름을 10글자 입력하세요"라고 했는데 100글자를 입력하면, 넘친 데이터가 옆에 있는 중요한 메모리 영역(예: 관리자 권한 플래그, 함수 복귀 주소)을 덮어써 버립니다.
이것이 해킹의 고전이자 제왕인 버퍼 오버플로우 공격입니다.
2. 세상을 뒤흔든 버퍼 오버플로우 사건들
이 취약점이 단순히 이론적인 것이 아님을 증명한 역사적인 사건들이 있습니다.
모리스 웜 (The Morris Worm, 1988)
인터넷 역사상 최초의 웜 바이러스입니다. 로버트 모리스라는 대학원생이 만든 이 프로그램은 fingerd라는 유닉스 데몬의 buffer overflow 취약점을 이용했습니다. gets() 함수를 통해 입력을 받을 때 길이를 검사하지 않는 점을 악용해 자신의 코드를 실행시켰고, 당시 인터넷에 연결된 전 세계 컴퓨터의 10%를 마비시켰습니다.
SQL 슬래머 (SQL Slammer, 2003)
마이크로소프트 SQL 서버의 버퍼 오버플로우 취약점을 이용했습니다. 단 376바이트의 작은 패킷 하나가 서버를 감염시켰고, 감염된 서버는 다시 무작위 IP로 패킷을 쏘아대며 기하급수적으로 퍼졌습니다. 단 10분 만에 전 세계 인터넷 트래픽을 마비시켰으며, 한국의 인터넷망이 불통 되는 '대란'을 일으킨 주범이기도 합니다.
3. 메모리 구조와 스택(Stack)의 비밀
프로그램이 함수를 호출할 때, Stack이라는 메모리 영역을 사용합니다.
스택에는 지역 변수(Local Variable)와 복귀 주소(Return Address)가 저장됩니다. 이 복귀 주소가 바로 해커의 타겟입니다.
스택의 구조 (위에서 아래로)
- 매개변수 (Parameters)
- 복귀 주소 (Return Address - RET): 함수 끝나고 돌아갈 곳.
- 이전 프레임 포인터 (Saved EBP): 이전 함수의 스택 기준점.
- 지역 변수 (Buffers):
char buffer[10]같은 데이터.
공격 시나리오
- 해커가
buffer에 10글자가 아니라 100글자를 입력합니다. - 데이터는 낮은 주소(Buffer)에서 높은 주소(RET) 방향으로 써집니다.
- 넘친 데이터가 스택을 타고 올라가서 Return Address가 적힌 공간을 덮어버립니다.
- 해커는 이 Return Address를 "자신이 심어둔 악성 코드(Shellcode)의 주소"로 바꿔치기합니다.
- 함수가 끝날 때 CPU는 "어, 돌아갈 주소가 여기네?" 하고 해커의 코드를 실행하러 점프합니다.
- 게임 오버. 해커가 시스템 통제권(Shell)을 얻습니다.
4. 전설의 함수: strcpy()
C언어를 배울 때 가장 먼저 배우면서, 가장 위험한 함수가 strcpy입니다.
void vulnerable_function(char *str) {
char buffer[10];
strcpy(buffer, str); // 길이 검사를 안 함!
}
strcpy는 "복사할 문자열이 버퍼보다 긴지" 검사하지 않습니다. 그저 NULL 문자(\0)가 나올 때까지 무작정 복사합니다.
그래서 현대 컴파일러에서는 strcpy 대신 strncpy, strlcpy 등 길이를 검사하는 안전한 함수 사용을 권장(강제)합니다.
gets() 함수는 아예 C11 표준에서 퇴출(Removed)당했습니다. 존재 자체가 보안 위협이기 때문입니다.
5. 방패와 창의 대결 - 보호 기법들
운영체제와 컴파일러 개발자들도 가만히 있지는 않았습니다. 버퍼 오버플로우를 막기 위한 3대 방어막이 등장했습니다.
1. 스택 카나리 (Stack Canary)
- 유래: 옛날 탄광 광부들이 유해가스를 탐지하기 위해 카나리아 새를 데리고 들어간 것에서 유래했습니다.
- 원리: Return Address 바로 앞에 '카나리'라 불리는 랜덤 값을 심어둡니다.
- 방어: 버퍼가 넘쳐서 Return Address를 덮으려면, 필연적으로 그 앞에 있는 카나리 값도 덮어써야 합니다.
- 함수가 끝나기 전에 "카나리 값이 변했나?" 확인합니다. 변했다면 "누가 메모리를 건드렸네!" 하고 프로그램을 즉시 강제 종료합니다 (
Stack Smashing Detected).
2. NX Bit (No-Execute) / DEP
- 원리: "데이터가 저장되는 곳(Stack, Heap)에서는 코드가 실행되지 않게 하자."
- 방어: 해커가 스택에 쉘코드를 심어두고 점프를 해도, CPU가 "어? 여기는 실행 권한이 없는 구역(No-Execute)인데?" 하고 거부합니다. 하드웨어 레벨의 방어입니다.
3. ASLR (Address Space Layout Randomization)
- 원리: 메모리 주소를 랜덤으로 섞어버립니다.
- 방어: 해커가 공격하려면 "악성 코드가 있는 주소"로 점프를 시켜야 하는데, 프로그램이 실행될 때마다 스택, 힙, 라이브러리의 주소가 계속 바뀝니다. 해커는 눈을 가리고 다트를 던지는 꼴이 됩니다.
6. 그러나... 공격은 계속된다 (ROP)
방어가 강력해지면 공격도 진화합니다. NX Bit와 ASLR을 우회하기 위해 해커들은 ROP (Return Oriented Programming)라는 기법을 창안했습니다.
- 개념: "내가 코드를 못 심는다면(NX Bit), 이미 메모리에 존재하는 코드 조각들(Gadgets)을 레고처럼 조립해서 쓰자."
libc같은 표준 라이브러리에는pop eax; ret;같은 기계어 코드 조각들이 무수히 많습니다.- 해커는 스택에 이 가젯들의 주소를 줄줄이 엮어(Chain) 놓습니다.
- 프로그램은
ret명령어를 만날 때마다 다음 가젯으로 점프하며, 해커가 의도한 동작을 수행합니다.
이것이 보안(Security)의 본질입니다. 끝없는 창과 방패의 싸움입니다.
7. 개발자가 해야 할 일
우리가 모든 해킹 기법을 알 필요는 없지만, 기본 원칙은 지켜야 합니다.
- Unsafe 함수 금지:
strcpy,gets,sprintf대신strncpy,fgets,snprintf를 사용하세요. - Modern Language: 가능하면 메모리를 직접 관리하지 않는 언어(Java, Python, Rust, Go)를 사용하세요. Rust는 메모리 안전성(Memory Safety)을 언어 차원에서 보장합니다.
- Secure Coding: 입력값 검증(Input Validation)은 보안의 제1원칙입니다. 사용자가 입력한 길이를 절대 믿지 마세요.