버퍼 오버플로란: 경계 밖 쓰기 원인과 C 코드 예방·검증

반응형

buffer overflow 또는 buffer overrun은 program이 준비된 memory buffer의 경계를 넘어 data를 읽거나 쓰는 문제를 가리킬 때 흔히 쓰는 말이다. 보안 문서에서는 결과를 더 정확히 구분하는 편이 좋다.

  • Out-of-bounds write: buffer 끝을 넘어 memory에 쓴다. MITRE CWE-787에 해당한다.
  • Out-of-bounds read: 허용 범위 밖의 memory를 읽는다. CWE-125에 해당한다.
  • Buffer underflow: 시작 주소보다 앞쪽을 접근한다.

이 가운데 out-of-bounds write는 인접 data, control data나 allocator metadata를 손상시킬 수 있다. crash로 끝날 수도 있지만 data 변조나 임의 code 실행으로 이어질 가능성도 있어 취약점으로 다뤄야 한다.

“buffer overrun의 장점과 단점”이라는 구분은 맞지 않는다. 경계 밖 접근 자체에는 활용할 장점이 없다. bounds check를 줄이면 빨라질 수 있다는 주장은 취약점의 장점이 아니라, 안전하지 않은 최적화가 만든 tradeoff일 뿐이다.

가장 흔한 원인

입력 길이보다 작은 destination

다음 C code는 input 길이를 확인하지 않고 8-byte array에 복사한다.

#include <string.h>

void copy_name(const char *input) {
    char name[8];
    strcpy(name, input);  // input이 7자를 넘으면 경계 밖 쓰기 가능
}

문자열 끝에는 null terminator \0도 필요하다. 화면에 보이는 글자가 8개라면 최소 9 byte가 필요하다는 점을 빼먹기 쉽다.

위 code는 취약한 형태를 설명하기 위한 예시다. 실제 process에서 긴 입력을 넣어 실행하지 않는다. memory corruption은 실행 결과가 예측 가능하지 않다.

단위와 integer 계산 오류

element 수와 byte 수를 혼동하거나, allocation 크기를 계산하는 integer가 overflow한 뒤 작은 buffer를 만들 수도 있다.

size_t bytes = count * sizeof(struct item);

count가 외부 입력이라면 곱셈 전에 SIZE_MAX / sizeof(struct item)보다 큰지 확인해야 한다. signed·unsigned 변환, 음수 length, off-by-one도 같은 계열의 원인이다.

unsafe API와 FFI

strcpy, sprintf, 길이 없는 memory copy는 destination 크기를 알지 못한다. Python, Java처럼 일반 container 접근을 bounds-check하는 언어도 C extension, ctypes, JNI와 unsafe FFI를 통과하면 같은 문제가 다시 생길 수 있다.

size를 API 계약에 포함한다

예방의 핵심은 destination pointer만 넘기지 않고 capacity를 함께 전달하는 것이다.

#include <stddef.h>
#include <stdio.h>

enum copy_result {
    COPY_ERROR = -1,
    COPY_OK = 0,
    COPY_TRUNCATED = 1
};

enum copy_result write_label(
    char *destination,
    size_t destination_size,
    const char *source
) {
    if (destination == NULL || source == NULL || destination_size == 0) {
        return COPY_ERROR;
    }

    int written = snprintf(destination, destination_size, "%s", source);
    if (written < 0) {
        destination[0] = '\0';
        return COPY_ERROR;
    }

    if ((size_t)written >= destination_size) {
        return COPY_TRUNCATED;
    }

    return COPY_OK;
}

snprintfdestination_size가 0보다 크면 결과를 null-terminate한다. 반환값이 buffer 크기 이상이면 출력이 잘렸다는 뜻이므로 성공과 구분해야 한다.

입력을 항상 잘라 저장하는 정책은 business rule에 맞지 않을 수도 있다. identifier, path, security token은 일부만 저장하면 더 위험해질 수 있다. 이런 값은 truncation 대신 입력을 거부하고 호출자에게 명확한 error를 돌려주는 편이 낫다.

strncpy가 자동으로 안전하지 않은 이유

strncpy(destination, source, n)은 이름 때문에 안전한 strcpy처럼 보이지만 사용법이 까다롭다.

  • source 길이가 n 이상이면 destination이 null-terminate되지 않을 수 있다.
  • source가 짧으면 남은 공간을 null byte로 채워 불필요한 비용이 생길 수 있다.
  • destination의 실제 capacity보다 큰 n을 주면 여전히 overflow한다.

어떤 함수를 쓰든 destination capacity, terminator 공간과 truncation 정책을 함께 검토해야 한다. 함수 이름만 바꾸는 것으로 해결되지 않는다.

test 단계에서 sanitizer로 잡는다

AddressSanitizer는 heap, stack, global의 out-of-bounds access와 use-after-free 등을 runtime에 탐지한다. UndefinedBehaviorSanitizer는 일부 array bound와 undefined behavior를 보완한다.

clang \
  -O1 -g \
  -Wall -Wextra -Wconversion \
  -fsanitize=address,undefined \
  -fno-omit-frame-pointer \
  sample.c -o sample

sanitizer는 test에서 실제로 실행된 path만 관찰한다. build에 flag를 추가했다고 모든 경계 오류가 없다는 증명이 되지는 않는다. 정상 입력뿐 아니라 다음을 포함한 unit test와 fuzzing을 함께 둔다.

  • 빈 문자열과 capacity 0
  • destination에 정확히 맞는 길이
  • terminator 한 byte가 부족한 길이
  • 매우 큰 length와 allocation 계산
  • multibyte encoding을 byte와 character 중 무엇으로 제한할지
  • 실패·truncation 반환값을 호출자가 무시하는 경우

compile·runtime 방어선

원인을 없애는 것이 먼저지만 exploit 가능성을 낮추는 방어도 겹쳐 둔다.

  • memory-safe language나 bounds-aware abstraction을 경계 처리에 사용
  • compiler warning을 CI에서 확인
  • AddressSanitizer·UndefinedBehaviorSanitizer와 fuzzing 실행
  • platform에 맞는 stack protector와 _FORTIFY_SOURCE 사용
  • ASLR, NX/DEP, control-flow protection 적용
  • dependency와 compiler security update 유지

stack canary나 ASLR은 이미 발생한 out-of-bounds write를 올바른 code로 바꾸지 않는다. 공격 성공 가능성을 낮추는 defense in depth다.

review checklist

  1. buffer의 단위가 byte인지 element인지 명확한가?
  2. null terminator 공간을 포함했는가?
  3. length 계산 전에 integer overflow를 확인하는가?
  4. destination capacity가 함수 signature에 전달되는가?
  5. truncation을 정상 처리할지 error로 볼지 정했는가?
  6. return value를 호출자가 확인하는가?
  7. FFI와 C extension 경계에도 같은 검증이 있는가?
  8. boundary case가 sanitizer·fuzz test에서 실행되는가?

buffer overflow는 성능 최적화의 반대편에 놓인 선택지가 아니다. program이 약속한 memory 범위를 벗어난 결함이다. 안전한 API 계약과 test, compiler·runtime 방어를 함께 적용해야 한다.

보안 공격 기법의 큰 흐름security·cryptography 기초 정리를 함께 보면 memory corruption이 system 방어에서 차지하는 위치도 연결할 수 있다.

참고 자료

반응형
KEEP READING
카테고리 전체 보기 →

댓글