버퍼 오버플로우가 무엇이고, 왜 발생하며, 어떻게 막는지

핵심은 “정해진 메모리 공간보다 많은 데이터를 집어넣을 때, 프로그램의 정상 흐름이 깨진다”는 것이다.

먼저 버퍼 오버플로우의 개념부터 보자. 프로그램은 실행될 때 메모리를 미리 정해진 구조로 나누어 사용한다. 그런데 개발자가 입력 크기를 제대로 제한하지 않으면, 사용자가 의도적으로 너무 큰 데이터를 넣어서 자기 몫이 아닌 메모리 영역까지 덮어쓰게 만들 수 있다. 이 현상이 바로 버퍼 오버플로우이고, 공격자는 이를 이용해 프로그램을 크래시시키거나, 심지어 자기 코드가 실행되도록 조작할 수 있다. 특히 C나 C++처럼 메모리 관리를 개발자가 직접 해야 하는 언어에서 자주 발생한다.

이를 이해하려면 프로세스 메모리 구조를 알아야 한다. 실행 중인 프로세스의 메모리는 크게 네 영역으로 나뉜다.
스택(Stack), 힙(Heap), 데이터(Data), 텍스트(Text) 영역이다.

스택 영역은 함수 실행과 직접 관련된 공간이다. 함수 안에서 선언한 지역변수, 함수에 전달된 인자 값, 그리고 함수가 끝난 뒤 되돌아갈 주소(복귀주소)가 이곳에 저장된다. 함수 호출이 중첩될수록 스택이 쌓였다가, 함수가 끝나면 다시 줄어든다. 스택 오버플로우 공격은 주로 이 영역에서 복귀주소를 덮어써서 공격자가 원하는 코드로 흐름을 바꾸는 방식으로 이루어진다.

힙 영역은 프로그램 실행 중에 동적으로 메모리를 할당할 때 사용된다. malloc() 같은 함수로 메모리를 요청하면 힙 영역에서 공간이 할당된다. 이 영역은 크기가 유동적이고, 개발자가 직접 free()로 해제해야 한다. 힙 오버플로우는 동적 메모리를 잘못 관리할 때 발생하며, 다른 객체나 함수 포인터를 덮어쓰는 방식으로 악용될 수 있다.

데이터 영역은 전역변수와 정적변수가 저장되는 공간이다. 프로그램 시작 시 할당되고 종료 시 해제되며, 자동으로 초기화되는 특징이 있다. 공격 표적이 되는 경우는 상대적으로 적지만, 중요 변수 조작이 가능하다.

텍스트 영역은 실제 프로그램 코드가 저장되는 공간이다. 읽기 전용(Read Only)으로 설정되어 있으며, 정상적인 경우 이 영역은 수정할 수 없다. 그래서 공격자는 보통 자신의 코드를 여기에 넣는 대신, 스택이나 힙에 코드를 심고 실행 흐름을 바꾸려 한다.

버퍼 오버플로우가 자주 발생하는 이유는 길이 제한이 없는 위험한 함수들 때문이다. strcpy, gets, scanf, sprintf 같은 함수들은 입력 길이를 검사하지 않는다. 그래서 버퍼 크기보다 큰 데이터가 들어와도 그대로 복사해 버린다. 반대로 strncpy, fgets, snprintf처럼 길이를 명시할 수 있는 함수들은 오버플로우를 예방할 수 있어 사용이 권장된다.

이런 공격을 막기 위해 운영체제와 컴파일러는 여러 보호 기법을 제공한다.
Stack Guard는 함수 진입 시 특정 값(canary)을 넣어두고, 함수 종료 시 그 값이 변했는지 확인해서 변조가 있으면 프로그램을 종료한다.
Stack Shield는 복귀주소를 별도의 안전한 공간에 저장해 두고, 돌아올 때 비교한다.
ASLR은 스택, 힙, 라이브러리의 메모리 주소를 실행할 때마다 랜덤하게 배치해서 공격자가 주소를 예측하지 못하게 한다.
DEP/NX bit는 스택과 힙 영역에서 코드를 실행하지 못하게 막아, “데이터 영역에서 코드 실행” 자체를 차단한다.
RELRO는 데이터 영역 일부를 읽기 전용으로 만들어 덮어쓰기를 방지한다.
PIE는 프로그램 코드 자체에도 ASLR을 적용해 주소 예측을 더 어렵게 만든다.

물론 공격자는 이를 우회하려는 기법도 개발했다. Return-to-libc(RTL)는 새로운 코드를 실행하지 않고, 이미 메모리에 올라와 있는 라이브러리 함수로 실행 흐름을 돌리는 방식이다. ROP(Return Oriented Programming)는 여러 짧은 코드 조각을 이어 붙여 보호 기법을 우회하는 고급 공격 기법이다.

정리하면, 버퍼 오버플로우는 “입력 검증을 하지 않은 프로그램”과 “메모리 구조의 특성”이 만나 발생하는 대표적인 취약점이다.