C++ 보안 프로그래밍 | 메모리 안전·인젝션·하드닝

이 글의 핵심

C++ 취약점의 대부분은 메모리 안전 문제에서 나옵니다. 이 글은 할당 크기 오버플로우, 스마트 포인터로 해결되지 않는 수명 버그, 인젝션, 민감 데이터 제로화의 함정을 다루고, 표준 라이브러리 하드닝 모드·컴파일러 보호 옵션·Sanitizer·퍼징으로 남은 버그를 찾는 방법을 정리합니다. OpenSSL로 난수·HMAC·AES-GCM을 올바르게 쓰는 코드는 #43-2에 있습니다.

들어가며: “우선 돌아가게”가 보안 버그를 만든다

C++는 금융, 게임, 임베디드, 브라우저, 서버처럼 공격 표면이 넓은 곳에서 쓰이지만, 언어가 메모리 접근을 검사하지 않기 때문에 범위를 벗어난 읽기·쓰기와 해제된 메모리 사용이 그대로 공격 수단이 됩니다. 대형 C/C++ 코드베이스를 가진 조직들이 공개한 분석에서도 심각한 보안 버그의 큰 비중이 메모리 안전 문제로 보고되고 있습니다.

이 글은 그중 메모리 안전과 입력 처리 쪽을 다룹니다. 암호화 API를 올바르게 쓰는 방법(암호학적 난수, HMAC, AES-GCM, 반환값과 태그 검증, TLS 설정)은 보안 코딩과 OpenSSL 연동 #43-2에 코드와 함께 정리되어 있습니다.

요구 환경: C++17 이상 (일부 예제는 C++20)


실제 문제 시나리오

시나리오 1: 할당 크기 오버플로우로 인한 힙 손상

사용자 입력: count=1000000, item_size=4096 (32비트 size_t 환경)
코드: buffer = malloc(count * item_size);
결과: 곱이 size_t 범위를 넘어 작은 값으로 래핑 → 작은 버퍼에 대량 쓰기 → 힙 손상

size_t는 부호 없는 타입이라 곱이 넘쳐도 정의된 동작(모듈러 산술)으로 조용히 작은 값이 됩니다. 컴파일러 경고도, 런타임 에러도 없습니다. 64비트에서도 두 값이 모두 공격자가 제어하는 크기라면 같은 일이 생깁니다.

시나리오 2: 반환된 string_view가 가리키는 임시 문자열

std::string_view file_extension(const std::string& path) {
    return path.substr(path.rfind('.'));   // substr은 임시 std::string을 반환
}   // 임시 문자열 파괴 → 반환된 view는 해제된 메모리를 가리킴

소유권 관리는 스마트 포인터가 해결했지만, 소유하지 않는 참조의 수명은 C++ 타입 시스템이 추적하지 않습니다. 이 함수는 경고 없이 컴파일되고, 대부분의 실행에서 “우연히” 옳은 값을 돌려주다가 할당 패턴이 바뀌면 깨집니다.

시나리오 3: SQL 인젝션, 명령어 인젝션, 포맷 스트링

query = "SELECT * FROM users WHERE id='" + user_input + "'";   // ' OR '1'='1
system(("convert " + filename).c_str());                          // "a.png; rm -rf ~"
printf(user_input);                                               // "%x%x%x%n"

세 가지 모두 데이터를 코드로 해석하는 경로에 외부 입력을 넣었다는 공통점이 있습니다.

시나리오 4: rand()로 만든 세션 토큰

rand()는 암호학적 난수가 아닙니다. 시드가 time(nullptr)이면 공격자는 서버 시작 시각 근처의 시드만 시험해 토큰을 재현할 수 있습니다. 토큰, 키, nonce에는 OS의 CSPRNG(getrandom, BCryptGenRandom)나 OpenSSL의 RAND_bytes를 씁니다. 사용법은 #43-2의 난수 절에 있습니다.


원칙: 어디서 검사할 것인가

보안 코드의 대부분은 “검사를 어디에 두는가”의 문제입니다.

  • 경계에서 한 번, 형식을 좁혀서. 네트워크·파일·사용자 입력이 들어오는 지점에서 길이, 범위, 허용 문자를 검사하고, 그 뒤로는 검증된 타입(예: UserId, std::span<const std::byte>)으로 넘깁니다. 검증되지 않은 std::string이 코드 깊숙이 흘러 다니면 어디서 검사했는지 아무도 모르게 됩니다.
  • 거부 목록보다 허용 목록. “위험한 문자를 제거”하는 방식은 인코딩 우회(%2e%2e/, 유니코드 정규화)에 약합니다. 허용된 형식만 통과시키는 편이 새 공격 기법에 덜 흔들립니다.
  • 실패하면 거부. 파싱 실패, 크기 계산 실패, 암호화 함수 실패는 모두 “요청 거부”로 끝나야 합니다. 기본값으로 계속 진행하는 코드가 가장 위험합니다.
  • 검사를 도구에 맡길 수 있는 부분은 맡깁니다. 사람이 모든 인덱스 검사를 기억하는 것은 불가능합니다. 5절의 하드닝 모드와 Sanitizer는 사람의 실수를 전제로 한 안전망입니다.

크기와 범위

할당 크기 계산

#include <cstddef>
#include <limits>
#include <memory>
#include <stdexcept>

// a * b가 넘치지 않으면 true
constexpr bool checked_mul(std::size_t a, std::size_t b, std::size_t& out) noexcept {
    if (b != 0 && a > std::numeric_limits<std::size_t>::max() / b) return false;
    out = a * b;
    return true;
}

std::unique_ptr<std::byte[]> alloc_items(std::size_t count, std::size_t item_size) {
    std::size_t total = 0;
    if (!checked_mul(count, item_size, total) || total > kMaxRequestBytes)
        throw std::length_error("allocation size rejected");
    return std::make_unique<std::byte[]>(total);
}

GCC와 Clang에서는 __builtin_mul_overflow(a, b, &out)이 같은 일을 하며 최적화도 잘 됩니다. 오버플로우 검사만으로는 부족하다는 점도 중요합니다. 넘치지 않는 4GB 요청도 서비스 거부 공격이 되므로, 요청 하나가 쓸 수 있는 상한(kMaxRequestBytes)을 비즈니스 규칙으로 정해 둡니다. new T[n]은 구현이 내부적으로 n * sizeof(T) 오버플로우를 검사해 std::bad_array_new_length를 던지지만, malloc(n * size)는 검사하지 않습니다.

부호 있는 크기와 부호 없는 크기

// 네트워크에서 읽은 길이 필드가 int32라면
int32_t len = read_i32(packet);
std::vector<char> buf(len);        // ❌ len이 음수면 거대한 size_t로 변환
if (len < 0 || static_cast<std::size_t>(len) > packet.remaining()) reject();
std::vector<char> buf(static_cast<std::size_t>(len));   // ✅ 검사 후 변환

부호 있는 값이 부호 없는 크기로 암묵 변환되는 지점은 공격자가 음수를 넣을 수 있는 곳입니다. -Wsign-conversion을 켜면 이런 변환을 경고로 볼 수 있습니다. 기존 코드베이스에서는 경고가 많이 나오지만, 파싱 코드처럼 외부 입력을 다루는 파일부터 적용할 가치가 있습니다. C++20의 std::cmp_less, std::in_range<T>(v)는 부호가 다른 정수를 올바르게 비교해 줍니다.

문자열 복사

// ❌ strcpy: 크기 검사 없음
// ⚠️ strncpy: 원본이 길면 널 종료 문자를 쓰지 않음 — "안전한 strcpy"가 아니다
strncpy(dest, src, sizeof(dest));

// ✅ C 인터페이스가 필요하면: 잘림을 감지하고 항상 종료
bool copy_cstr(char* dest, std::size_t dest_size, const char* src) {
    if (dest_size == 0) return false;
    std::size_t n = strnlen(src, dest_size);
    if (n == dest_size) {                 // 들어갈 자리 없음 (종료 문자 포함)
        dest[0] = '\0';
        return false;
    }
    std::memcpy(dest, src, n + 1);
    return true;
}

strncpy는 원래 고정 폭 필드용 함수라서, 원본이 버퍼보다 길면 널 종료 없이 끝납니다. 그 버퍼를 나중에 strlen이나 printf("%s")로 읽으면 범위를 넘어 읽게 됩니다. 새 코드에서는 std::string과 std::string_view, 경계에서만 C 문자열로 변환하는 방식이 가장 단순합니다. snprintf는 잘림이 일어나도 항상 종료하며, 반환값이 버퍼 크기 이상이면 잘렸다는 뜻이므로 반환값을 확인합니다.

범위가 명시된 뷰

#include <span>
void parse_header(std::span<const std::byte> data) {
    if (data.size() < 8) throw std::runtime_error("short header");
    auto magic = data.first(4);          // 크기가 함께 다니는 부분 뷰
    auto rest  = data.subspan(8);
    // ...
}

포인터와 길이를 따로 넘기면 둘이 어긋나는 버그가 생깁니다. std::span은 둘을 한 값으로 묶어 부분 범위를 만들 때 계산 실수를 줄입니다. 다만 span::operator[]는 표준상 범위를 검사하지 않으므로(C++26에서 at() 추가), 검사가 필요하면 5절의 하드닝 모드를 켭니다.


수명: 스마트 포인터가 막지 못하는 것

std::unique_ptr과 std::shared_ptr로 소유권을 표현하면 해제 누락과 이중 해제는 대부분 사라집니다. 남는 것은 소유하지 않는 참조의 댕글링입니다.

임시 객체를 가리키는 뷰

std::string_view sv = std::string("temp");      // ❌ 문장이 끝나면 임시 문자열 파괴
for (char c : get_config().name()) { }          // ❌ C++20까지: get_config() 임시 객체가 루프 전에 파괴될 수 있음

범위 기반 for의 두 번째 경우는 name()이 참조를 반환할 때 생기는 문제로, C++23에서 임시 객체 수명 연장 규칙이 바뀌어 해결되었지만 이전 표준으로 컴파일하는 코드에서는 여전히 유효한 함정입니다. Clang의 -Wdangling, GCC 13+의 -Wdangling-reference가 일부를 잡아 줍니다. 함수가 string_view나 span을 반환한다면 “무엇의 수명에 묶여 있는가”를 이름이나 주석으로 드러내고, Clang의 [[clang::lifetimebound]] 속성으로 컴파일러에 알려 줄 수 있습니다.

반복자와 참조 무효화

std::vector<Session> sessions;
Session& s = sessions.front();
sessions.push_back(new_session);   // 재할당 → s는 해제된 메모리를 가리킴
s.touch();                          // use-after-free

std::vector는 용량을 넘으면 새 버퍼로 옮기므로, 기존 원소에 대한 참조·포인터·반복자가 모두 무효화됩니다. std::unordered_map도 재해시 때 반복자가 무효화됩니다(참조는 유지). 원소의 주소를 오래 들고 있어야 한다면 std::deque(끝에 추가할 때 참조 유지), 노드 기반 컨테이너, 또는 인덱스·핸들로 참조하는 방식을 씁니다.

콜백과 비동기 작업

void Connection::start_read() {
    socket_.async_read(buf_, [this](auto ec, std::size_t n) {   // ❌ this가 먼저 파괴될 수 있음
        handle(n);
    });
}

비동기 콜백이 실행되는 시점에 객체가 이미 없어졌다면 this는 댕글링 포인터입니다. shared_from_this()로 콜백이 객체를 붙잡게 하거나, weak_ptr로 붙잡아 콜백 시점에 살아 있는지 확인합니다. 네트워크 서버의 use-after-free 상당수가 이 패턴에서 나옵니다.


인젝션

SQL: 파라미터 바인딩

sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db, "SELECT name FROM users WHERE id = ?", -1, &stmt, nullptr) != SQLITE_OK)
    throw db_error(db);
sqlite3_bind_text(stmt, 1, user_id.data(), static_cast<int>(user_id.size()), SQLITE_TRANSIENT);

바인딩된 값은 SQL 파서를 거치지 않으므로 어떤 문자열이 와도 쿼리 구조가 바뀌지 않습니다. 이스케이프 함수로 직접 따옴표를 처리하는 방식은 문자 인코딩과 DB별 규칙 차이 때문에 우회되기 쉽습니다. 테이블 이름이나 정렬 컬럼처럼 바인딩할 수 없는 부분은 허용 목록에서 고른 상수만 문자열에 넣습니다.

명령어: 셸을 거치지 않기

#include <spawn.h>
#include <sys/wait.h>
extern char** environ;

int run(const std::string& program, std::vector<std::string> args) {
    std::vector<char*> argv;
    argv.push_back(const_cast<char*>(program.c_str()));
    for (auto& a : args) argv.push_back(a.data());
    argv.push_back(nullptr);
    pid_t pid;
    if (posix_spawn(&pid, program.c_str(), nullptr, nullptr, argv.data(), environ) != 0) return -1;
    int status = 0;
    waitpid(pid, &status, 0);
    return status;
}

system()과 popen()은 문자열을 셸(/bin/sh -c)에 넘기므로 ;, |, $()가 모두 해석됩니다. 인자를 배열로 넘기는 posix_spawn이나 execv 계열은 셸을 거치지 않습니다. 단, 인자 배열이라도 호출되는 프로그램이 인자를 옵션으로 해석할 수 있습니다. 파일 이름이 -rf나 --output=/etc/passwd처럼 -로 시작하면 옵션으로 읽히므로, 사용자 입력 앞에 --를 넣어 옵션 파싱을 끝내거나 경로를 ./로 시작하게 만듭니다. program은 절대 경로로 지정해 PATH 조작을 피합니다.

포맷 스트링

printf("%s", user_input);          // ✅ 입력은 항상 인자로
std::format("{}", user_input);     // ✅ C++20: 포맷 문자열은 컴파일 시점 검사

printf(user_input)에서 입력에 %n이 있으면 스택의 포인터 위치에 값을 씁니다. GCC/Clang의 -Wformat -Wformat-security는 리터럴이 아닌 포맷 문자열을 경고합니다. C++20 std::format은 포맷 문자열이 컴파일 시점 상수여야 하므로(런타임 문자열은 std::vformat을 명시적으로 써야 함) 이 버그를 구조적으로 막습니다.

경로 탐색

namespace fs = std::filesystem;
std::optional<fs::path> resolve_under(const fs::path& root, const std::string& user_path) {
    fs::path candidate = fs::weakly_canonical(root / user_path);
    auto [r, c] = std::mismatch(root.begin(), root.end(), candidate.begin(), candidate.end());
    if (r != root.end()) return std::nullopt;     // root 밖으로 나감 ("../../etc/passwd")
    return candidate;
}

root는 미리 canonical로 정규화해 둡니다. 이 검사와 실제 파일 열기 사이에 심볼릭 링크가 바뀌는 경쟁(TOCTOU)까지 막으려면 openat에 O_NOFOLLOW나 Linux의 openat2(RESOLVE_BENEATH)처럼 커널 수준의 제한을 써야 합니다.


민감 데이터와 메모리

제로화가 제거되지 않게

#include <openssl/crypto.h>
void wipe(void* p, std::size_t n) {
    OPENSSL_cleanse(p, n);          // 또는 explicit_bzero / SecureZeroMemory / memset_explicit(C23)
}

일반 memset은 이후 읽지 않는 버퍼에 대한 쓰기이므로 최적화로 제거될 수 있습니다. 위 함수들은 제거되지 않도록 보장합니다.

복사본이 남는 곳

제로화 함수를 올바르게 써도, 비밀은 생각보다 많은 곳에 복사됩니다.

  • std::string/std::vector가 커지며 재할당하면 옛 버퍼는 지워지지 않고 해제됩니다. 크기를 미리 reserve하거나 고정 크기 버퍼를 씁니다.
  • std::string의 작은 문자열 최적화(SSO) 때문에 짧은 비밀번호는 객체 안에 들어가 있어, 객체를 이동해도 원래 위치에 남습니다.
  • 스왑 파일과 코어 덤프에 메모리가 기록될 수 있습니다. 키를 담은 페이지는 mlock으로 스왑을 막고, Linux에서는 madvise(MADV_DONTDUMP)로 코어 덤프에서 제외할 수 있습니다.
class SecretBuffer {
public:
    explicit SecretBuffer(std::size_t n) : data_(n) {}      // 생성 후 크기 변경 없음
    ~SecretBuffer() { OPENSSL_cleanse(data_.data(), data_.size()); }
    SecretBuffer(const SecretBuffer&) = delete;
    SecretBuffer& operator=(const SecretBuffer&) = delete;
    std::span<std::byte> bytes() { return data_; }
private:
    std::vector<std::byte> data_;
};

복사를 금지하고 크기를 고정하면 “어딘가에 복사본이 남는” 경로 대부분이 사라집니다. 완벽한 보장은 아니지만(레지스터, 스택 임시값), 흔한 누출 경로는 막습니다.

에러 메시지와 로그

예외 메시지에 파일 경로나 SQL, 키 일부가 들어가 사용자 응답으로 나가는 경우가 많습니다. 내부 로그와 사용자 응답을 분리하고, 사용자에게는 추적 ID만 돌려준 뒤 자세한 내용은 내부 로그에서 찾게 합니다. 로그에 토큰이나 비밀번호를 남기지 않도록, 로깅 계층에서 알려진 필드 이름(authorization, password)을 마스킹하는 편이 호출하는 쪽의 주의에 기대는 것보다 확실합니다.


하드닝: 남은 버그를 공격 불가능하게

아무리 조심해도 버그는 남습니다. 하드닝은 남은 버그가 크래시로 끝나고 공격으로 이어지지 않게 하는 층입니다.

표준 라이브러리 하드닝 모드

라이브러리켜는 방법효과
libstdc++-D_GLIBCXX_ASSERTIONSvector::operator[], string::operator[], front()/back() 등의 범위 검사
libc++ (LLVM 18+)-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST범위 검사 등 비용이 낮은 검사를 릴리스에서도
MSVC STL_CONTAINER_DEBUG_LEVEL, 디버그 빌드의 반복자 검사디버그 중심

vector[i]의 범위 초과는 “정의되지 않은 동작”에서 “즉시 중단”으로 바뀝니다. 비용은 대부분의 코드에서 측정하기 어려울 만큼 작아서, 여러 배포판과 대형 프로젝트가 릴리스 빌드에서도 켜고 있습니다. 핫 루프에서 비용이 확인되면 그 부분만 .data() 포인터로 접근하는 식으로 예외를 두는 편이, 전체를 끄는 것보다 낫습니다.

컴파일러·링커 옵션

# GCC/Clang, Linux 기준
-O2 -D_FORTIFY_SOURCE=3          # memcpy·strcpy 등을 크기를 아는 경우 검사 버전으로 (최적화 필요)
-fstack-protector-strong         # 스택 카나리
-fstack-clash-protection         # 큰 스택 할당이 가드 페이지를 건너뛰지 않게
-fcf-protection=full             # x86 CET 제어 흐름 보호 (지원 CPU에서)
-ftrivial-auto-var-init=zero     # 초기화하지 않은 지역 변수를 0으로 (정보 누출 방지)
-fPIE -pie                       # ASLR 적용 실행 파일
-Wl,-z,relro,-z,now              # GOT 읽기 전용 (Full RELRO)
-Wl,-z,noexecstack

_FORTIFY_SOURCE는 최적화가 켜져 있어야 동작하고, 레벨 3은 GCC 12/Clang 15 이상에서 동적 크기까지 검사합니다. 배포판 컴파일러가 이미 일부를 기본으로 켜는 경우가 많으니(gcc -Q --help=common으로 확인), 빌드 시스템에서 명시해 두면 환경에 따라 빠지는 일을 막을 수 있습니다. 빌드 결과는 checksec 같은 도구로 확인합니다.

초기화하지 않은 메모리

초기화하지 않은 지역 변수나 구조체 패딩이 네트워크로 전송되면 스택의 이전 내용(포인터, 키 일부)이 새어 나갑니다. 구조체를 통째로 send하지 말고 필드 단위로 직렬화하며, -ftrivial-auto-var-init=zero는 이 종류의 누출에 대한 안전망이 됩니다. C++26은 초기화하지 않은 변수 읽기를 “잘못된 동작(erroneous behavior)“으로 규정하는 방향으로 표준을 바꾸고 있습니다.


찾기: Sanitizer와 퍼징

도구찾는 것사용 시점
AddressSanitizer (-fsanitize=address)힙·스택 범위 초과, use-after-free, 이중 해제테스트·퍼징 빌드
UndefinedBehaviorSanitizer (-fsanitize=undefined)부호 있는 정수 오버플로우, 잘못된 시프트, 널 역참조, 정렬 위반테스트 빌드
-fsanitize=unsigned-integer-overflow (Clang)부호 없는 래핑 (UB는 아니지만 크기 계산 버그 탐지)파서 코드 선택 적용
ThreadSanitizer데이터 레이스멀티스레드 테스트
libFuzzer / AFL++위 도구와 결합해 입력을 자동 생성파서·프로토콜 코드

Sanitizer는 실행된 경로의 버그만 찾습니다. 테스트가 정상 입력만 넣는다면 공격자가 넣을 비정상 입력 경로는 검사되지 않습니다. 그래서 외부 입력을 파싱하는 코드에는 퍼저를 붙이는 것이 가장 효과가 큽니다.

// fuzz_header.cpp — clang++ -g -O1 -fsanitize=fuzzer,address fuzz_header.cpp parser.cpp
#include <cstddef>
#include <cstdint>
#include <span>
#include "parser.h"

extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
    try {
        parse_header(std::as_bytes(std::span(data, size)));
    } catch (const std::exception&) {
        // 예외로 거부하는 것은 정상 동작. 크래시나 Sanitizer 보고만 버그.
    }
    return 0;
}

퍼저를 몇 시간만 돌려도 손으로 쓴 테스트가 놓친 경계 조건(길이 0, 최대값, 중첩 깊이)이 나오는 경우가 많습니다. 찾은 입력은 회귀 테스트 코퍼스로 저장해 CI에서 매번 재실행합니다. Sanitizer별 설정과 출력 읽는 법은 Sanitizers #16-2에 있습니다.


체크리스트

  • 외부 입력은 경계에서 길이·범위·형식을 검사하고, 검증된 타입으로 넘긴다
  • 할당 크기는 오버플로우 검사 + 요청당 상한
  • 부호 있는 길이 필드는 음수 검사 후 변환 (-Wsign-conversion)
  • strcpy·strncpy 대신 std::string, C 경계에서는 잘림을 감지하는 복사
  • string_view·span·반복자를 반환하거나 저장할 때 대상의 수명을 확인
  • 비동기 콜백은 shared_from_this/weak_ptr로 객체 수명을 붙잡는다
  • SQL은 바인딩, 명령은 셸 없이 인자 배열 + --, 포맷 문자열은 상수
  • 비밀은 제로화 함수로 지우고, 재할당·복사가 없는 버퍼에 담는다
  • 릴리스 빌드에 표준 라이브러리 하드닝 모드와 컴파일러 보호 옵션
  • 파서·프로토콜 코드에 ASan + 퍼저
  • 암호화 코드는 #43-2의 체크리스트로 따로 점검

정리

항목핵심
크기곱셈 오버플로우 검사, 요청 상한, 부호 변환 검사
수명스마트 포인터로 소유권은 해결, 뷰·반복자·콜백의 댕글링은 별도로 관리
인젝션데이터가 코드로 해석되는 경로(SQL, 셸, 포맷, 경로)에 입력을 넣지 않기
민감 데이터제거되지 않는 제로화, 복사본이 남는 경로 차단, 로그 마스킹
하드닝_GLIBCXX_ASSERTIONS/libc++ 하드닝, _FORTIFY_SOURCE, 스택 보호, RELRO
탐지ASan·UBSan + 퍼저, 찾은 입력은 회귀 코퍼스로

같이 보면 좋은 글