본문으로 건너뛰기

42. 버퍼 오버플로우 기초: 바이너리 익스플로잇의 첫걸음

버퍼 오버플로우는 “긴 문자열을 넣으면 코드 실행”이라는 공식이 아니다. 경계 밖 쓰기가 어떤 객체를 덮고, 그 값이 이후 제어 흐름이나 보안 결정에 사용되는지가 핵심이다. 아래 실습은 자기 소유의 로컬 프로그램과 CTF 바이너리로만 수행한다.

메모리 경계부터 읽기

#include <stdio.h>
#include <string.h>

static void copy_name(const char *input) {
char buffer[64];
strcpy(buffer, input); /* 실습용으로 의도된 결함 */
puts(buffer);
}

int main(int argc, char **argv) {
if (argc == 2) copy_name(argv[1]);
}

strcpy는 목적지 크기를 알지 못하므로 입력이 64바이트에 들어맞는다는 계약이 없으면 경계를 넘을 수 있다. 실제 오프셋은 컴파일러, ABI, 정렬, 프레임 포인터 사용 여부에 따라 달라진다. 따라서 “64바이트 뒤가 반환 주소”라고 가정하지 말고 디버거와 메모리 관찰로 확인한다. 스택은 높은 주소에서 낮은 주소로 자라는 식으로 단순화해 그리는 경우가 많지만, 방향과 프레임 배치는 아키텍처와 최적화에 의존한다.

clang -g -O0 -fno-stack-protector -Wno-deprecated-declarations demo.c -o demo
file ./demo
checksec --file=./demo

보호를 끈 빌드는 학습용 비교 대상일 뿐이다. ASLR은 주소를 흔들고, stack canary는 특정 스택 손상을 탐지하며, NX는 데이터 영역 실행을 막고, PIE는 주 실행 파일의 배치를 랜덤화한다. RELRO는 동적 링커 데이터의 쓰기 가능 범위를 줄인다. 보호 기능을 확인했다고 취약점의 존재나 부재가 증명되는 것은 아니다.

안전한 분석 루틴

먼저 정상 입력, 경계 길이, 초과 입력에서 종료 코드와 sanitizer 보고를 비교한다. GDB·pwndbg로 어떤 바이트가 어느 객체를 덮는지 확인하고, -fsanitize=address,undefined 빌드로 원인을 재현한다. CTF에서 제어 흐름 전환을 연구하더라도 로컬 프로세스에 한정하고, 원격 서비스에는 문제 제공자가 명시한 범위와 속도 제한만 따른다. 실제 서비스에서 발견하면 페이로드를 시험하기보다 최소 재현과 영향, 수정 검증을 보고한다.

방어의 우선순위는 경계 검사를 호출자에게 맡기지 않는 것이다. 길이 인자를 받는 API, 안전한 파싱, 컴파일러 경고와 sanitizer, stack protector·PIE·RELRO·NX를 함께 적용하고, 퍼징으로 경계 조건을 회귀 테스트한다. 입력을 잘라 충돌만 숨기는 방식은 데이터 잘림과 논리 버그를 남길 수 있다.

버퍼 오버플로우를 배우는 이유는 오래된 공격 기법을 복제하기 위해서가 아니라, 메모리 안전성이라는 추상화가 어디에서 깨지고 어떤 방어가 실제로 그 비용을 줄이는지 판단하기 위해서다.