C++ 버퍼 오버플로우: strcpy·오프바이원 실수가 생기는 원리와 방지·탐지 방법

이 글의 핵심

버퍼 오버플로우는 배열이나 버퍼의 경계를 넘어 메모리에 쓰는 오류로, 프로그램이 그냥 죽는 것으로 끝나지 않고 반환 주소나 힙 메타데이터를 덮어써 임의 코드 실행으로 이어질 수 있는 C/C++ 고전적인 취약점입니다. 이 글은 왜 이 문제가 지금도 반복되는지, 실제로 어떤 패턴에서 발생하는지, 그리고 표준 라이브러리와 컴파일러 옵션으로 어떻게 막는지 정리합니다.

Buffer Overflow란?

버퍼 오버플로우는 정해진 크기의 버퍼에 그 크기보다 많은 데이터를 써서, 버퍼 바로 뒤에 있는 다른 메모리 영역까지 덮어써 버리는 오류입니다.

// ❌ 버퍼 오버플로우
char buffer[10];
strcpy(buffer, "This is too long");  // 범위 초과

이 코드는 컴파일도 되고, 실행해도 대부분의 경우 당장 크래시가 나지 않습니다. buffer 바로 뒤에 있던 다른 지역 변수나 함수의 반환 주소가 조용히 덮어써질 뿐입니다. 문제는 그 다음 함수가 리턴할 때 나타납니다 — 덮어써진 반환 주소로 실행 흐름이 점프하면서 세그멘테이션 폴트가 나거나, 공격자가 그 주소를 의도적으로 조작한 값으로 채워 넣었다면 임의의 코드가 실행됩니다. 1988년 모리스 웜이 인터넷 최초의 대규모 확산 사고를 일으킨 방법이 바로 유닉스 fingerd의 gets() 버퍼 오버플로우였고, 이후 30년 넘게 스택 카나리, ASLR, DEP/NX 같은 완화 기법이 나왔음에도 여전히 CWE-120(버퍼 오버플로우)은 CVE 데이터베이스에서 꾸준히 상위권에 오르는 취약점 유형입니다.

왜 여전히 반복되는가

경계 검사가 없는 함수들이 C 표준 라이브러리에 그대로 남아 있고, 레거시 코드베이스나 성능이 민감한 저수준 코드에서 여전히 쓰이기 때문입니다.

// 1. 안전하지 않은 문자열 함수
char buf[10];
strcpy(buf, "Long string");  // 범위 초과
gets(buf);  // 크기 제한 없음

// 2. 배열 인덱스 초과
int arr[10];
arr[15] = 42;  // 범위 초과

// 3. 포인터 산술
char* ptr = buffer;
ptr[20] = 'x';  // 범위 초과

// 4. 메모리 복사
char src[20] = "Hello";
char dst[5];
memcpy(dst, src, 20);  // 범위 초과

이 네 가지 패턴의 공통점은 함수 시그니처만 봐서는 위험을 알 수 없다는 것입니다. strcpy(dst, src)는 dst가 몇 바이트인지 함수 안에서 전혀 알 수 없고, 호출하는 쪽이 항상 안전한 크기를 보장해야 합니다. 이 “암묵적 계약”이 코드 리뷰에서 놓치기 쉬운 지점이고, 특히 리팩터링 과정에서 버퍼 크기만 바뀌고 관련된 strcpy/memcpy 호출부의 길이 인자를 갱신하지 않는 실수가 실무에서 반복적으로 나옵니다.

보안 위험 — 왜 단순 버그가 아닌가

스택 버퍼 오버플로우는 지역 변수 공간을 넘어 함수의 반환 주소까지 덮어쓸 수 있어서 단순한 논리 오류가 아니라 제어 흐름 탈취로 이어집니다.

// 스택 버퍼 오버플로우 공격
void vulnerable(const char* input) {
    char buffer[64];
    strcpy(buffer, input);  // 입력 크기 미검증
    // 반환 주소 덮어쓰기 가능
}

// 힙 버퍼 오버플로우
void heapOverflow() {
    char* buffer = new char[64];
    strcpy(buffer, longString);  // 힙 메타데이터 손상
    delete[] buffer;
}

힙 버퍼 오버플로우는 스택보다 발견이 더 어렵습니다. 힙 청크의 메타데이터(크기, 다음/이전 청크 포인터)를 덮어써도 당장은 아무 증상이 없다가, 한참 뒤 그 메모리 영역이 free()되거나 재할당될 때 완전히 무관해 보이는 위치에서 크래시가 나기 때문에, 오버플로우가 발생한 지점과 크래시가 관찰되는 지점이 코드상 멀리 떨어져 있는 경우가 많습니다. 이런 종류의 버그를 잡을 때 스택 트레이스만 보고 원인을 추적하려 하면 시간을 크게 낭비하게 되는데, AddressSanitizer로 실행하면 실제로 경계를 넘는 바로 그 순간에 즉시 멈춰주기 때문에 원인 추적 시간이 극적으로 줄어듭니다.

실전 예시

예시 1: 문자열 함수

strcpy는 목적지 버퍼의 크기를 전혀 모르는 채로 소스 문자열 끝(\0)까지 무조건 복사합니다. strncpy는 최대 길이를 지정할 수 있지만, 소스가 그 길이보다 길면 널 종료 문자를 쓰지 않는다는 함정이 있어 여전히 주의가 필요합니다.

#include <cstring>
#include <string>

// ❌ 안전하지 않은 함수
void unsafeCopy(const char* src) {
    char buffer[10];
    strcpy(buffer, src);  // 범위 초과 가능
}

// ✅ 안전한 함수
void safeCopy(const char* src) {
    char buffer[10];
    strncpy(buffer, src, sizeof(buffer) - 1);
    buffer[sizeof(buffer) - 1] = '\0';
}

// ✅ std::string 사용
void useString(const char* src) {
    std::string str = src;  // 자동 크기 조정
}

새 코드를 작성한다면 strncpy의 함정까지 신경 쓰기보다 std::string을 기본값으로 삼는 편이 훨씬 안전합니다. std::string은 내부적으로 필요한 만큼 힙을 재할당하므로 애초에 “버퍼 크기”라는 개념 자체가 호출자의 책임에서 빠집니다.

예시 2: 배열 접근

#include <iostream>
#include <vector>
#include <stdexcept>

// ❌ 범위 검사 없음
void unsafeAccess(int index) {
    int arr[10];
    arr[index] = 42;  // index >= 10이면 위험
}

// ✅ 범위 검사
void safeAccess(int index) {
    int arr[10];
    if (index >= 0 && index < 10) {
        arr[index] = 42;
    }
}

// ✅ std::vector with at()
void vectorAccess(int index) {
    std::vector<int> vec(10);
    try {
        vec.at(index) = 42;  // 범위 검사
    } catch (const std::out_of_range& e) {
        std::cerr << "범위 초과: " << e.what() << std::endl;
    }
}

operator[]는 표준상 범위 검사를 요구하지 않습니다(성능을 위한 의도적 설계). 반면 .at()은 범위를 벗어나면 std::out_of_range를 던지므로, 인덱스가 신뢰할 수 없는 입력(사용자 입력, 파싱 결과)에서 왔다면 operator[] 대신 .at()을 기본으로 쓰는 것이 안전합니다. 다만 핫 루프 안에서는 .at()의 예외 처리 오버헤드가 누적될 수 있으므로, 이런 경우 루프 진입 전에 한 번만 범위를 검증하고 루프 내부는 operator[]로 접근하는 절충안을 씁니다.

예시 3: 사용자 입력

#include <iostream>
#include <string>

// ❌ gets() 사용
void unsafeInput() {
    char buffer[64];
    gets(buffer);  // 위험! (C++14에서 제거됨)
}

// ✅ fgets() 사용
void safeInput() {
    char buffer[64];
    if (fgets(buffer, sizeof(buffer), stdin)) {
        buffer[strcspn(buffer, "\n")] = '\0';
    }
}

// ✅ std::getline 사용
void cppInput() {
    std::string input;
    std::getline(std::cin, input);
}

gets()는 입력 길이를 아예 제한할 방법이 없는 유일한 표준 함수였기 때문에 C++11에서 사용 중단(deprecated), C++14에서 완전히 표준에서 제거됐습니다. 컴파일러가 gets 호출에 경고나 에러를 낸다면 그건 버그가 아니라 표준이 의도적으로 막아둔 것입니다.

예시 4: 메모리 복사

#include <cstring>
#include <algorithm>

// ❌ 크기 검증 없음
void unsafeCopy(const char* src, size_t srcLen) {
    char dst[64];
    memcpy(dst, src, srcLen);  // srcLen > 64이면 위험
}

// ✅ 크기 검증
void safeCopy(const char* src, size_t srcLen) {
    char dst[64];
    size_t copySize = std::min(srcLen, sizeof(dst) - 1);
    memcpy(dst, src, copySize);
    dst[copySize] = '\0';
}

// ✅ std::copy with 범위 검사
void cppCopy(const char* src, size_t srcLen) {
    std::vector<char> dst(64);
    size_t copySize = std::min(srcLen, dst.size() - 1);
    std::copy(src, src + copySize, dst.begin());
    dst[copySize] = '\0';
}

여기서 한 가지 더 조심할 것은 길이 계산 자체의 정수 오버플로입니다. size_t total = headerLen + bodyLen;처럼 두 길이를 더해 할당 크기를 구하면, 두 값이 모두 외부 입력일 때 합이 SIZE_MAX를 넘어 작은 값으로 감싸질(wrap-around) 수 있습니다. 그러면 작은 버퍼가 할당되고, 이어지는 memcpy는 원래의 큰 길이로 복사해 힙 오버플로가 됩니다. 할당 전에 if (bodyLen > SIZE_MAX - headerLen) 같은 검사를 넣거나, calloc(n, size)처럼 곱셈 오버플로를 내부에서 검사하는 API를 쓰는 이유가 이것입니다.

memcpy의 세 번째 인자(길이)가 상수가 아니라 외부에서 들어온 값이라면, 그 값 자체가 공격 표면입니다. 네트워크 프로토콜을 파싱하는 코드에서 패킷에 실려온 “길이” 필드를 검증 없이 그대로 memcpy 길이로 넘기는 실수는 실제 CVE에서 반복적으로 등장하는 패턴이므로, 외부에서 온 길이 값은 항상 목적지 버퍼 크기와 std::min으로 클램프한 뒤에만 써야 합니다.

크래시 메시지로 원인 구분하기

같은 오버플로라도 어떤 보호 장치가 잡았는지에 따라 출력되는 메시지가 다르고, 메시지를 보면 다음에 무엇을 확인해야 할지 알 수 있습니다.

  • *** stack smashing detected ***: terminated — glibc의 스택 카나리 검사가 함수 리턴 직전에 실패한 것입니다. 오버플로가 일어난 곳은 방금 리턴하려던 함수의 지역 버퍼이므로, 그 함수 안의 배열과 복사 호출부터 봅니다. 크래시 위치가 함수 끝(})으로 찍히는 것이 정상이라 처음 보면 헷갈립니다.
  • *** buffer overflow detected ***: terminated — _FORTIFY_SOURCE가 컴파일 타임에 목적지 크기를 알 수 있는 memcpy/strcpy/sprintf 호출을 __memcpy_chk 같은 검사 버전으로 바꿔 두었고, 그 검사가 실패한 경우입니다. 스택 트레이스에 __chk_fail이 보이면 이쪽입니다.
  • ERROR: AddressSanitizer: stack-buffer-overflow / heap-buffer-overflow — ASan 빌드에서 경계를 넘는 바로 그 읽기/쓰기 명령에서 멈춘 것입니다. 보고서의 WRITE of size N 줄과 첫 번째 스택 프레임이 곧 원인 위치이고, 아래쪽의 “is located N bytes to the right of M-byte region” 설명이 몇 바이트 넘어갔는지 알려 줍니다.
  • 아무 메시지 없는 Segmentation fault — 보호 옵션 없이 빌드됐거나, 덮어쓴 값이 한참 뒤에 사용된 경우입니다. 이때는 원인을 추측하기보다 ASan 빌드로 다시 재현하는 편이 빠릅니다.

주의할 점은 _FORTIFY_SOURCE가 최적화(-O1 이상)와 함께 빌드해야만 동작한다는 것입니다. -O0 디버그 빌드에서는 조용히 무시되고, 최근 glibc는 -O0에서 이 매크로를 쓰면 _FORTIFY_SOURCE requires compiling with optimization 경고를 냅니다. 디버그 빌드에서 멀쩡하던 코드가 릴리스 빌드에서만 buffer overflow detected로 죽는다면 버그가 새로 생긴 것이 아니라 원래 있던 오버플로를 릴리스 빌드의 검사가 비로소 잡은 것입니다.

자주 발생하는 문제

문제 1: sprintf vs snprintf

// ❌ sprintf (크기 제한 없음)
void unsafeFormat(int value) {
    char buffer[10];
    sprintf(buffer, "Value: %d", value);  // 범위 초과 가능
}

// ✅ snprintf (크기 제한)
void safeFormat(int value) {
    char buffer[10];
    snprintf(buffer, sizeof(buffer), "Value: %d", value);
}

// ✅ std::ostringstream
void cppFormat(int value) {
    std::ostringstream oss;
    oss << "Value: " << value;
    std::string result = oss.str();
}

sprintf는 포맷 문자열과 인자에 따라 출력 길이를 컴파일 타임에 알 수 없는데도 목적지 버퍼 크기를 인자로 받지 않는다는 점에서 설계 자체가 위험합니다. snprintf로 바꾸는 건 기계적인 치환이라 리팩터링 비용이 거의 들지 않으므로, 레거시 코드에서 sprintf를 발견하면 가장 먼저 손대볼 만한 부분입니다.

snprintf의 반환값도 챙겨야 합니다. 반환값은 “실제로 쓴 바이트 수”가 아니라 버퍼가 충분했다면 썼을 길이입니다. 그래서 int n = snprintf(buf, sizeof(buf), ...); if (n < 0 || n >= (int)sizeof(buf))로 잘림(truncation)을 확인해야 하고, 여러 조각을 이어 붙이려고 pos += snprintf(buf + pos, sizeof(buf) - pos, ...)를 반복하는 코드에서 이 검사를 빠뜨리면 pos가 버퍼 크기를 넘어서고, 다음 호출의 sizeof(buf) - pos가 음수가 아니라 거대한 size_t 값이 되어 다시 오버플로가 납니다. “안전한 함수”로 바꿨는데도 오버플로가 나는 대표적인 경우입니다.

문제 2: 문자열 연결

// ❌ strcat (크기 검사 없음)
void unsafeConcat() {
    char buffer[10] = "Hello";
    strcat(buffer, " World");  // 범위 초과
}

// ✅ strncat (크기 제한)
void safeConcat() {
    char buffer[20] = "Hello";
    strncat(buffer, " World", sizeof(buffer) - strlen(buffer) - 1);
}

// ✅ std::string
void cppConcat() {
    std::string str = "Hello";
    str += " World";
}

strncat의 세 번째 인자는 목적지 버퍼 전체 크기가 아니라 덧붙일 최대 문자 수입니다. strncat(buffer, src, sizeof(buffer))처럼 전체 크기를 넘기는 실수가 흔한데, 이미 들어 있는 문자열 길이와 널 종료 문자를 빼지 않았으므로 여전히 오버플로가 납니다. 인자 의미가 strncpy와 달라 헷갈리기 쉬우므로, 이런 연결 작업은 std::string으로 옮기는 것이 가장 확실합니다.

문제 3: 배열 초기화

초기화하지 않은 스택 버퍼는 버퍼 오버플로우 자체는 아니지만, 정보 유출로 이어질 수 있는 인접한 문제입니다. 이전 스택 프레임에 남아 있던 값(비밀번호, 포인터 주소 등)이 그대로 남아 있다가 이 버퍼를 로그로 출력하거나 네트워크로 내보내면 그 값이 그대로 노출됩니다.

// ❌ 초기화 없음
void uninitializedBuffer() {
    char buffer[64];
    // buffer에 쓰레기 값
}

// ✅ 초기화
void initializedBuffer() {
    char buffer[64] = {0};
    // 또는
    char buffer2[64];
    memset(buffer2, 0, sizeof(buffer2));
}

// ✅ std::array
void cppArray() {
    std::array<char, 64> buffer = {0};
}

문제 4: 오프바이원 에러

// ❌ 널 종료 문자 공간 부족
void offByOne() {
    char buffer[5];
    strncpy(buffer, "Hello", 5);  // 널 종료 없음
}

// ✅ 널 종료 공간 확보
void correctSize() {
    char buffer[6];
    strncpy(buffer, "Hello", 5);
    buffer[5] = '\0';
}

"Hello"는 문자 5개지만 널 종료 문자까지 포함하면 6바이트가 필요합니다. char buffer[5]에 strncpy(buffer, "Hello", 5)를 호출하면 5바이트가 전부 문자로 채워지고 널 종료 문자를 쓸 공간이 남지 않아, 이후 이 버퍼를 문자열 함수(strlen, printf("%s", ...) 등)에 넘기면 버퍼 뒤의 메모리까지 계속 읽어나가는 별개의 오버리드(over-read) 버그로 이어집니다. “문자열 길이 + 1”을 버퍼 크기로 잡는 습관을 들이는 것만으로 이 종류의 실수는 대부분 예방됩니다.

방지 방법

// 1. 안전한 함수 사용
strncpy(dst, src, sizeof(dst) - 1);
snprintf(buffer, sizeof(buffer), format, args);
fgets(buffer, sizeof(buffer), stdin);

// 2. C++ 표준 라이브러리
std::string str;
std::vector<char> buffer;
std::array<char, 64> arr;

// 3. 범위 검사
if (index >= 0 && index < size) {
    arr[index] = value;
}

// 4. 컴파일러 보호
// -fstack-protector-all
// -D_FORTIFY_SOURCE=2

-fstack-protector-all은 함수 프롤로그에 “카나리 값”을 심어두고 함수가 리턴하기 직전 그 값이 그대로인지 검사합니다. 스택 버퍼 오버플로우로 반환 주소까지 덮어썼다면 카나리 값도 함께 덮어써지므로 리턴 직전에 크래시를 일으켜, 공격자가 의도한 반환 주소로 점프하기 전에 프로그램을 강제 종료시킵니다. 참고로 -fstack-protector-all은 모든 함수에 검사를 넣어 오버헤드가 크기 때문에, 배포 빌드에서는 배열이나 주소를 취한 지역 변수가 있는 함수에만 넣는 -fstack-protector-strong이 더 일반적인 선택입니다. 여러 리눅스 배포판의 기본 패키지 빌드 플래그도 -strong 쪽을 씁니다. 또 libstdc++에서는 -D_GLIBCXX_ASSERTIONS를 켜면 std::vector::operator[], std::string::operator[] 같은 접근에 경계 검사 단언이 들어가므로, 테스트 빌드에서 .at()으로 코드를 바꾸지 않고도 인덱스 초과를 잡을 수 있습니다.

이건 오버플로우 자체를 막는 게 아니라 “악용되기 전에 죽여서” 피해를 제한하는 완화 기법이라는 점은 구분해서 이해해야 합니다 — 근본적인 해결책은 여전히 경계 검사입니다.

탐지 도구

# AddressSanitizer
g++ -fsanitize=address -g program.cpp

# Valgrind
valgrind --tool=memcheck ./program

# Static Analysis
clang-tidy program.cpp
cppcheck program.cpp

# 런타임 검사
# Windows: /GS (Buffer Security Check)
# Linux: -fstack-protector-all

실무에서는 이 도구들을 조합해서 씁니다. Valgrind는 정확하지만 실행 속도가 원래보다 10~50배 느려져 CI에서 매 커밋마다 돌리기엔 부담스럽고, AddressSanitizer는 오버헤드가 2배 내외라 CI 파이프라인에 상시로 켜두기에 더 현실적입니다. 개인적으로는 로컬 개발 중에는 항상 ASan을 켠 빌드로 테스트를 돌리고, 릴리스 직전에만 Valgrind로 한 번 더 확인하는 방식이 속도와 정확도의 균형이 가장 좋았습니다.

이 도구들이 잡지 못하는 경우도 알아 두어야 합니다. ASan은 실제로 실행된 경로만 검사하므로, 테스트가 긴 입력을 한 번도 넣어 보지 않았다면 오버플로가 있는 코드도 통과합니다. 그래서 파서나 프로토콜 처리 코드는 libFuzzer(-fsanitize=fuzzer,address)나 AFL++ 같은 퍼저와 ASan을 함께 돌려 경계 입력을 자동으로 만들어 내는 것이 효과적입니다. 또 같은 구조체 안의 인접 필드로 넘치는 경우(struct { char name[8]; int role; }에서 name이 role을 덮는 경우)는 할당 단위 안쪽이라 ASan이 기본 설정으로는 보고하지 않습니다. 이런 구조체 내부 오버플로는 결국 크기 검사와 코드 리뷰로 막아야 합니다.

보안 모범 사례

// 1. 입력 검증
void validateInput(const char* input, size_t maxLen) {
    if (strlen(input) > maxLen) {
        throw std::invalid_argument("입력 너무 김");
    }
}

// 2. 경계 검사
template<typename T, size_t N>
class SafeArray {
    T data[N];
public:
    T& operator[](size_t index) {
        if (index >= N) {
            throw std::out_of_range("인덱스 초과");
        }
        return data[index];
    }
};

// 3. RAII 사용
class Buffer {
    std::unique_ptr<char[]> data;
    size_t size;
public:
    Buffer(size_t s) : data(std::make_unique<char[]>(s)), size(s) {}

    void write(const char* src, size_t len) {
        if (len > size) {
            throw std::length_error("버퍼 초과");
        }
        memcpy(data.get(), src, len);
    }
};

SafeArray처럼 경계 검사를 캡슐화한 래퍼 타입을 하나 만들어두면, 이후 이 타입을 쓰는 모든 코드는 검사 로직을 매번 반복해서 작성할 필요가 없어집니다. 팀 단위로 작업한다면 이런 안전한 래퍼를 공용 라이브러리로 만들어두고 “원시 배열 직접 사용 금지”를 코드 리뷰 체크리스트에 넣는 것이, 개인의 주의력에 의존하는 것보다 훨씬 안정적으로 오버플로우를 막아줍니다.

다만 모든 곳을 std::string과 예외로 바꾸는 것이 항상 정답은 아닙니다. 예외를 끈(-fno-exceptions) 임베디드 펌웨어나 인터럽트 핸들러처럼 동적 할당을 피해야 하는 코드에서는 std::array와 명시적 길이 검사, 그리고 C++20의 std::span처럼 “포인터 + 길이”를 한 객체로 묶어 넘기는 방식이 현실적인 절충안입니다. std::span은 할당을 하지 않으면서도 함수 시그니처에 길이를 포함시키므로, 앞에서 말한 “호출자가 크기를 보장해야 하는 암묵적 계약”을 타입으로 드러내 줍니다.

FAQ

Q1: Buffer Overflow는 언제 발생하나요?

A: 안전하지 않은 문자열/메모리 함수를 쓰거나, 배열 인덱스가 경계를 벗어나거나, 사용자 입력의 길이를 검증하지 않은 경우 발생합니다.

Q2: 보안 위험은 구체적으로 무엇인가요?

A: 반환 주소를 덮어써 임의 코드 실행으로 이어지거나, 권한 상승, 크래시를 통한 서비스 거부로 이어질 수 있습니다.

Q3: 방지 방법은?

A: 크기를 함께 관리하는 표준 라이브러리 타입(std::string, std::vector, std::array)을 쓰고, 경계 검사를 빠뜨리지 않으며, 컴파일러의 스택 보호 옵션을 켭니다.

Q4: 어떤 도구로 탐지하나요?

A: 개발 중에는 AddressSanitizer, 정밀 분석에는 Valgrind, 커밋 전에는 clang-tidy/cppcheck 같은 정적 분석 도구를 함께 씁니다.

Q5: 어떤 함수가 안전한 대안인가요?

A: strcpy/gets/sprintf/strcat 대신 strncpy/fgets/snprintf/strncat을, 궁극적으로는 std::string과 std::vector를 씁니다.


같이 보면 좋은 글