C++ 보안 코딩 가이드: 오버플로우 방지와 암호화 라이브러리(OpenSSL) 실전 연동
들어가며: “우선 돌아가게”가 보안 버그를 만든다
오버플로우와 암호화 실수
SSL/TLS 글에서 암호화 통신을 다뤘다면, 이번 글은 보안 코딩 자체에 집중합니다. 연산 결과가 타입 범위를 넘는 정수 오버플로우와, 할당된 영역 밖에 쓰는 버퍼 오버플로우는 메모리 손상을 거쳐 임의 코드 실행으로 이어질 수 있습니다. 암호화 쪽에서는 난수 품질, 키 관리, 모드와 패딩 선택의 실수 하나로 통신이 탈취되거나 위조됩니다.
C++에서는 크기를 계산할 때 오버플로우를 검사하는 연산과 길이를 아는 API를 쓰고, OpenSSL을 쓸 때는 핸들을 RAII로 감싸고 모든 반환값을 확인하는 습관이 필요합니다. 이 글은 오버플로우 방지 코드, OpenSSL 초기화와 에러 처리, 난수·HMAC·AES-GCM·TLS 설정 예제, 그리고 흔한 취약점 순서로 진행합니다.
실제 문제 시나리오
시나리오 1, 할당 크기 오버플로우로 인한 힙 버퍼 오버플로우:
32비트 환경, 사용자 입력: count=1048577, item_size=4096
개발자 코드: buffer = malloc(count * item_size);
결과: 실제 곱 4,294,971,392는 32비트 size_t 최댓값(4,294,967,295)을 넘어
4096으로 래핑됨
→ malloc(4096) 후 count개 항목을 쓰면 힙 손상, 원격 코드 실행 가능
64비트 환경에서도 두 값이 모두 공격자가 제어하는 입력이라면 같은 일이 생길 수 있습니다. 곱셈 결과를 믿기 전에 넘치는지부터 확인해야 합니다.
시나리오 2, rand()로 생성한 세션 토큰 예측:
개발자: session_token = rand() % 1000000;
공격자: rand() 시드가 time(NULL)이면 1초 내 가능한 값만 브루트포스
→ 세션 하이재킹
시나리오 3, EVP 함수 반환값 미검사:
개발자: EVP_EncryptUpdate(ctx, out, &len, in, in_len); // 반환값 무시
실제: 내부 에러 시 0 반환, out에 쓰레기 또는 부분 암호문
→ 복호화 실패, 데이터 손상, 또는 정보 유출
시나리오 4, SSL_CTX_set_verify 생략:
개발자: SSL_CTX만 생성하고 verify 모드 설정 안 함
결과: 클라이언트의 기본값은 SSL_VERIFY_NONE이라 서버 인증서가 가짜여도 핸드셰이크 성공
→ MITM 공격에 취약
아래에서는 이런 문제를 막는 패턴과 동작하는 예제를 봅니다.
정수·버퍼 오버플로우 방지
정수·버퍼 안전
부호 있는 정수의 a + b가 타입 범위를 넘으면 정의되지 않은 동작이고, 부호 없는 정수는 조용히 래핑됩니다. 어느 쪽이든 할당 크기나 인덱스 계산에 쓰이면 위험하므로, 계산하기 전에 std::numeric_limits로 상한을 검사하거나 GCC·Clang의 __builtin_mul_overflow 같은 검사 연산을 씁니다. C++20의 std::in_range는 서로 다른 정수 타입 사이 변환이 안전한지 확인할 때 유용합니다.
버퍼 쪽에서는 길이 제한이 없는 sprintf, strcpy 대신 snprintf나 std::string, std::span을 쓰고, 쓰기 전에 크기가 버퍼 이하인지 확인합니다. strncpy는 원본이 길면 널 종료 문자를 붙이지 않으므로 안전한 대안이 아닙니다. 이런 실수는 정적 분석(#41-1)과 AddressSanitizer·UBSan으로 상당수 찾아낼 수 있습니다.
배열 인덱스를 size_t로 받으면 음수가 들어올 수 없어 보이지만, 호출하는 쪽에서 음수 int를 넘기면 아주 큰 값으로 변환됩니다. 부호 있는 값을 받는 경계에서 먼저 검증하고, 접근 전에 범위를 확인합니다.
오버플로우 방지 코드 예제
정수 오버플로우 검사(안전한 할당 크기 계산):
#include <limits>
#include <cstddef>
#include <memory>
#include <stdexcept>
// a * b가 오버플로우 없이 계산 가능한지 검사
bool safe_multiply(size_t a, size_t b, size_t& out) {
if (a == 0 || b == 0) {
out = 0;
return true;
}
if (a > std::numeric_limits<size_t>::max() / b)
return false;
out = a * b;
return true;
}
// 사용 예: 버퍼 할당 전 검사
void allocate_buffer(size_t count, size_t item_size) {
size_t total;
if (!safe_multiply(count, item_size, total))
throw std::overflow_error("allocation size overflow");
auto buf = std::make_unique<char[]>(total);
// ...
}
곱셈이 넘치지 않더라도 결과가 비정상적으로 크다면 메모리 고갈 공격이 될 수 있으므로, 실제 서비스에서는 요청 하나가 할당할 수 있는 최대 크기도 함께 제한합니다.
버퍼 쓰기 전 크기 검증:
#include <cstdio>
#include <cstdarg>
#include <cstdint>
#include <span>
#include <string_view>
// ❌ 위험: 크기 제한 없음
void bad_copy(char* dest, const char* src) {
// strcpy(dest, src); // 버퍼 오버플로우
}
// ✅ 안전: snprintf 또는 std::string 사용
bool safe_format(char* dest, size_t dest_size, const char* fmt, ...) {
if (dest_size == 0) return false;
va_list args;
va_start(args, fmt);
int n = vsnprintf(dest, dest_size, fmt, args);
va_end(args);
return n >= 0 && static_cast<size_t>(n) < dest_size; // 잘렸으면 false
}
// ✅ C++20: std::span으로 포인터와 길이를 함께 전달
void process_span(std::span<const uint8_t> data) {
for (size_t i = 0; i < data.size(); ++i) {
// bounds-safe 접근
}
}
vsnprintf는 버퍼를 넘치게 쓰지는 않지만 출력이 잘릴 수 있습니다. 반환값이 버퍼 크기 이상이면 잘린 것이므로, 잘린 문자열을 그대로 쓰면 안 되는 경우(경로, SQL 조각 등)에는 반드시 확인합니다.
배열 인덱스 검증:
#include <vector>
#include <cstddef>
#include <stdexcept>
template<typename T>
T& safe_at(std::vector<T>& v, size_t i) {
if (i >= v.size())
throw std::out_of_range("index out of range");
return v[i];
}
// 외부에서 signed 값을 받는 경우: 음수 먼저 검증
template<typename T>
T& safe_at_signed(std::vector<T>& v, ptrdiff_t i) {
if (i < 0 || static_cast<size_t>(i) >= v.size())
throw std::out_of_range("index out of range");
return v[static_cast<size_t>(i)];
}
OpenSSL C++ 연동
초기화·에러·RAII
OpenSSL은 C API라서 에러를 에러 큐에 쌓아 두고, 호출한 쪽이 직접 꺼내 봐야 합니다. ERR_get_error()로 에러 코드를 꺼내고 ERR_error_string_n()으로 메시지로 바꿉니다. 모든 반환값을 검사하고, 실패하면 정리한 뒤 곧바로 반환하는 것이 원칙입니다. 에러 큐는 스레드별로 따로 있으며, 꺼내지 않고 두면 다음 호출의 에러 원인을 헷갈리게 만들 수 있습니다.
EVP_*, SSL_CTX, BIO 같은 객체는 할당한 뒤 반드시 해당 _free 함수로 해제해야 합니다. C++에서는 unique_ptr에 커스텀 삭제자를 주는 방식이 가장 간단합니다. 삭제자 구조체의 operator()에서 EVP_PKEY_free 등을 호출하게 해 두면, unique_ptr가 소멸할 때 자동으로 해제됩니다. EVP_PKEY_new() 같은 생성 함수는 실패하면 nullptr를 돌려주므로 if (!key)로 확인하고 에러 큐를 봅니다. 다른 타입도 같은 방식으로 UniqueXXX 별칭을 만들어 쓰면 됩니다.
초기화는 OpenSSL 1.1.0부터 자동으로 이루어지므로 보통 따로 호출할 필요가 없습니다. 옵션을 지정해야 할 때만 OPENSSL_init_ssl을 명시적으로 부릅니다. 1.1.0 이상은 별도의 락 콜백 없이도 멀티스레드에서 안전하게 쓸 수 있습니다.
RAII 래퍼 정의
#include <memory>
#include <openssl/evp.h>
#include <openssl/ssl.h>
#include <openssl/bio.h>
#include <openssl/err.h>
struct EVP_PKEY_Deleter { void operator()(EVP_PKEY* p) const { EVP_PKEY_free(p); } };
using UniqueEVP_PKEY = std::unique_ptr<EVP_PKEY, EVP_PKEY_Deleter>;
struct EVP_MD_CTX_Deleter { void operator()(EVP_MD_CTX* p) const { EVP_MD_CTX_free(p); } };
using UniqueEVP_MD_CTX = std::unique_ptr<EVP_MD_CTX, EVP_MD_CTX_Deleter>;
struct EVP_CIPHER_CTX_Deleter { void operator()(EVP_CIPHER_CTX* p) const { EVP_CIPHER_CTX_free(p); } };
using UniqueEVP_CIPHER_CTX = std::unique_ptr<EVP_CIPHER_CTX, EVP_CIPHER_CTX_Deleter>;
struct SSL_CTX_Deleter { void operator()(SSL_CTX* p) const { SSL_CTX_free(p); } };
using UniqueSSL_CTX = std::unique_ptr<SSL_CTX, SSL_CTX_Deleter>;
struct BIO_Deleter { void operator()(BIO* p) const { BIO_free(p); } };
using UniqueBIO = std::unique_ptr<BIO, BIO_Deleter>;
UniqueEVP_PKEY key(EVP_PKEY_new());
if (!key) {
// ERR_get_error(); 로 에러 큐 확인
return;
}
에러 큐 확인 유틸리티
#include <string>
#include <sstream>
std::string get_openssl_errors() {
std::ostringstream oss;
unsigned long err;
while ((err = ERR_get_error()) != 0) {
char buf[256];
ERR_error_string_n(err, buf, sizeof(buf));
oss << buf << "; ";
}
return oss.str();
}
// 사용 예
int ret = EVP_EncryptUpdate(ctx, out, &len, in, in_len);
if (ret != 1) {
std::cerr << "EVP_EncryptUpdate failed: " << get_openssl_errors() << "\n";
return -1;
}
OpenSSL 초기화
#include <openssl/ssl.h>
#include <openssl/err.h>
void init_openssl() {
#if OPENSSL_VERSION_NUMBER >= 0x10100000L
// 1.1.0+ : 자동 초기화. 옵션을 명시하고 싶을 때만 호출
if (OPENSSL_init_ssl(OPENSSL_INIT_LOAD_SSL_STRINGS | OPENSSL_INIT_LOAD_CRYPTO_STRINGS, nullptr) != 1) {
// 초기화 실패
}
#else
// 1.0.x : 명시적 초기화 필요 (이미 지원 종료된 버전)
SSL_library_init();
SSL_load_error_strings();
OpenSSL_add_all_algorithms();
#endif
}
난수, HMAC, AES-GCM, TLS 컨텍스트 예제
암호학적 난수 생성
#include <openssl/rand.h>
#include <cstdint>
#include <vector>
#include <stdexcept>
std::vector<uint8_t> secure_random_bytes(size_t n) {
std::vector<uint8_t> buf(n);
if (RAND_bytes(buf.data(), static_cast<int>(n)) != 1) {
throw std::runtime_error("RAND_bytes failed");
}
return buf;
}
// 세션 토큰 생성 (32바이트 = 256비트)
std::vector<uint8_t> generate_session_token() {
return secure_random_bytes(32);
}
RAND_bytes는 운영체제의 엔트로피로 시드된 CSPRNG에서 바이트를 뽑습니다. std::mt19937처럼 빠르고 통계적으로 좋은 난수 생성기도 출력 몇백 개를 관찰하면 내부 상태를 복원할 수 있으므로 토큰이나 키에는 쓰면 안 됩니다.
HMAC-SHA256 (메시지 무결성 검증)
#include <openssl/evp.h>
#include <openssl/hmac.h>
#include <openssl/crypto.h>
#include <cstdint>
#include <string>
#include <vector>
#include <stdexcept>
std::vector<uint8_t> hmac_sha256(const uint8_t* key, size_t key_len,
const uint8_t* data, size_t data_len) {
unsigned int len = 0;
std::vector<uint8_t> out(EVP_MAX_MD_SIZE);
unsigned char* result = HMAC(EVP_sha256(), key, static_cast<int>(key_len),
data, data_len,
out.data(), &len);
if (!result) {
throw std::runtime_error("HMAC failed");
}
out.resize(len);
return out;
}
// 헬퍼: 바이트를 hex 문자열로
std::string bytes_to_hex(const std::vector<uint8_t>& bytes) {
static const char hex[] = "0123456789abcdef";
std::string s;
for (uint8_t b : bytes) {
s += hex[b >> 4];
s += hex[b & 0xf];
}
return s;
}
// 사용 예: API 서명 검증 (CRYPTO_memcmp로 타이밍 공격 방지)
bool verify_api_signature(const std::string& secret,
const std::string& payload,
const std::string& received_signature_hex) {
auto mac = hmac_sha256(
reinterpret_cast<const uint8_t*>(secret.data()), secret.size(),
reinterpret_cast<const uint8_t*>(payload.data()), payload.size());
std::string computed_hex = bytes_to_hex(mac);
return computed_hex.size() == received_signature_hex.size() &&
CRYPTO_memcmp(computed_hex.data(), received_signature_hex.data(),
computed_hex.size()) == 0;
}
HMAC은 비밀 키를 가진 쪽만 올바른 태그를 만들 수 있으므로, 웹훅이나 API 요청이 위조되지 않았는지 확인하는 데 씁니다. 받은 서명을 hex 그대로 비교하므로 상대가 대문자 hex를 보내면 불일치가 납니다. 실제 코드에서는 받은 hex를 바이트로 디코딩한 뒤 바이트끼리 비교하는 편이 안전합니다.
AES-256-GCM 대칭 암호화 (인증 암호화)
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <openssl/err.h>
#include <cstdint>
#include <vector>
#include <stdexcept>
#include <cstring>
struct AesGcmResult {
std::vector<uint8_t> ciphertext;
std::vector<uint8_t> tag; // 16 bytes for GCM
std::vector<uint8_t> iv; // 12 bytes recommended for GCM
};
AesGcmResult aes_gcm_encrypt(const uint8_t* key, size_t key_len,
const uint8_t* plaintext, size_t plain_len,
const uint8_t* aad, size_t aad_len) {
if (key_len != 32) throw std::invalid_argument("key must be 32 bytes");
EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
if (!ctx) throw std::runtime_error("EVP_CIPHER_CTX_new failed");
AesGcmResult result;
result.iv.resize(12);
if (RAND_bytes(result.iv.data(), 12) != 1) {
EVP_CIPHER_CTX_free(ctx);
throw std::runtime_error("RAND_bytes failed");
}
if (EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, key, result.iv.data()) != 1) {
EVP_CIPHER_CTX_free(ctx);
throw std::runtime_error("EVP_EncryptInit_ex failed");
}
if (aad_len > 0 && EVP_EncryptUpdate(ctx, nullptr, nullptr, aad, static_cast<int>(aad_len)) != 1) {
EVP_CIPHER_CTX_free(ctx);
throw std::runtime_error("EVP_EncryptUpdate AAD failed");
}
result.ciphertext.resize(plain_len + EVP_CIPHER_block_size(EVP_aes_256_gcm()));
int out_len = 0;
if (EVP_EncryptUpdate(ctx, result.ciphertext.data(), &out_len, plaintext, static_cast<int>(plain_len)) != 1) {
EVP_CIPHER_CTX_free(ctx);
throw std::runtime_error("EVP_EncryptUpdate failed");
}
result.ciphertext.resize(out_len);
int final_len = 0;
if (EVP_EncryptFinal_ex(ctx, result.ciphertext.data() + out_len, &final_len) != 1) {
EVP_CIPHER_CTX_free(ctx);
throw std::runtime_error("EVP_EncryptFinal_ex failed");
}
result.ciphertext.resize(out_len + final_len);
result.tag.resize(16);
if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, result.tag.data()) != 1) {
EVP_CIPHER_CTX_free(ctx);
throw std::runtime_error("EVP_CTRL_GCM_GET_TAG failed");
}
EVP_CIPHER_CTX_free(ctx);
return result;
}
GCM은 스트림 방식이라 암호문 길이가 평문 길이와 같고 패딩이 없습니다. IV(nonce)는 비밀일 필요는 없지만 같은 키로 절대 재사용하면 안 되므로, 암호문과 함께 저장하거나 전송합니다. 12바이트 무작위 nonce는 같은 키로 대략 2^32개 메시지까지 쓰는 것이 권장 한도이므로, 그보다 많이 암호화한다면 키를 교체합니다. AAD(추가 인증 데이터)에는 암호화하지 않지만 변조되면 안 되는 값, 예를 들어 레코드 ID나 버전을 넣습니다. 에러 경로마다 EVP_CIPHER_CTX_free를 반복하는 번거로움은 위의 UniqueEVP_CIPHER_CTX를 쓰면 사라집니다.
TLS 클라이언트 컨텍스트 설정 (보안 강화)
#include <openssl/ssl.h>
#include <openssl/err.h>
SSL_CTX* create_secure_client_ctx() {
const SSL_METHOD* method = TLS_client_method();
SSL_CTX* ctx = SSL_CTX_new(method);
if (!ctx) return nullptr;
// 인증서 체인 검증 필수
SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, nullptr);
SSL_CTX_set_verify_depth(ctx, 5);
// 시스템 CA 저장소 로드
if (SSL_CTX_set_default_verify_paths(ctx) != 1) {
SSL_CTX_free(ctx);
return nullptr;
}
// 약한 프로토콜 비활성화
if (SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION) != 1) {
SSL_CTX_free(ctx);
return nullptr;
}
// TLS 1.3 암호 스위트 (TLS 1.2 스위트는 SSL_CTX_set_cipher_list로 따로 지정)
if (SSL_CTX_set_ciphersuites(ctx, "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256") != 1) {
SSL_CTX_free(ctx);
return nullptr;
}
return ctx;
}
// 연결마다: 호스트 이름 검증과 SNI 설정
// SSL* ssl = SSL_new(ctx);
// SSL_set1_host(ssl, "api.example.com"); // 인증서의 이름 확인
// SSL_set_tlsext_host_name(ssl, "api.example.com"); // SNI
여기서 가장 자주 빠지는 것이 호스트 이름 검증입니다. SSL_VERIFY_PEER는 인증서가 신뢰할 수 있는 CA에서 발급된 유효한 체인인지만 확인하고, 그 인증서가 지금 접속하려는 서버의 이름으로 발급된 것인지는 확인하지 않습니다. 이 확인이 없으면 공격자가 자기 도메인으로 정상 발급받은 인증서만 있어도 중간자 공격이 성공합니다. 연결마다 SSL_set1_host로 기대하는 호스트 이름을 지정해야 합니다.
SSL_CTX_set_ciphersuites는 TLS 1.3에만 적용되고, TLS 1.2의 암호 스위트는 SSL_CTX_set_cipher_list로 설정합니다. OpenSSL의 기본 TLS 1.3 스위트는 이미 안전한 것들이라, 특별한 요구가 없다면 1.2 쪽만 약한 스위트를 제외하는 것으로 충분합니다.
메모리의 비밀 값 지우기
#include <openssl/crypto.h>
#include <cstdint>
#include <vector>
void secure_zero(void* ptr, size_t len) {
OPENSSL_cleanse(ptr, len);
}
// 사용 예: 키 사용 후 제로화
void use_key_then_clear(std::vector<uint8_t>& key) {
// ... 키 사용 ...
secure_zero(key.data(), key.size());
key.clear();
}
memset(ptr, 0, len)은 그 뒤에 메모리를 읽는 코드가 없으면 컴파일러가 쓸모없는 쓰기로 보고 지워 버릴 수 있습니다. OPENSSL_cleanse는 이런 최적화로 제거되지 않도록 만들어져 있습니다. 다만 std::vector가 재할당하면서 남긴 이전 버퍼나 std::string의 복사본까지는 지우지 못하므로, 키를 담는 컨테이너는 크기를 미리 정해 두고 복사를 줄이는 편이 좋습니다.
AES-GCM 복호화 (태그 검증 포함)
std::vector<uint8_t> aes_gcm_decrypt(const uint8_t* key, size_t key_len,
const uint8_t* iv, size_t iv_len,
const uint8_t* ciphertext, size_t cipher_len,
const uint8_t* tag, size_t tag_len,
const uint8_t* aad, size_t aad_len) {
if (key_len != 32 || iv_len != 12 || tag_len != 16)
throw std::invalid_argument("invalid key/iv/tag length");
EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
if (!ctx) throw std::runtime_error("EVP_CIPHER_CTX_new failed");
if (EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, key, iv) != 1) {
EVP_CIPHER_CTX_free(ctx);
throw std::runtime_error("EVP_DecryptInit_ex failed");
}
if (aad_len > 0 && EVP_DecryptUpdate(ctx, nullptr, nullptr, aad, static_cast<int>(aad_len)) != 1) {
EVP_CIPHER_CTX_free(ctx);
throw std::runtime_error("EVP_DecryptUpdate AAD failed");
}
std::vector<uint8_t> plaintext(cipher_len);
int out_len = 0;
if (EVP_DecryptUpdate(ctx, plaintext.data(), &out_len, ciphertext, static_cast<int>(cipher_len)) != 1) {
EVP_CIPHER_CTX_free(ctx);
throw std::runtime_error("EVP_DecryptUpdate failed");
}
// 태그 설정: 반드시 DecryptFinal 전에 호출
if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, 16, const_cast<uint8_t*>(tag)) != 1) {
EVP_CIPHER_CTX_free(ctx);
throw std::runtime_error("EVP_CTRL_GCM_SET_TAG failed");
}
int final_len = 0;
if (EVP_DecryptFinal_ex(ctx, plaintext.data() + out_len, &final_len) != 1) {
OPENSSL_cleanse(plaintext.data(), plaintext.size());
EVP_CIPHER_CTX_free(ctx);
throw std::runtime_error("EVP_DecryptFinal_ex failed: tag mismatch (tampered?)");
}
plaintext.resize(out_len + final_len);
EVP_CIPHER_CTX_free(ctx);
return plaintext;
}
EVP_DecryptUpdate는 태그를 확인하기 전에 이미 평문을 버퍼에 써 넣습니다. EVP_DecryptFinal_ex가 실패했다면 태그가 맞지 않는 것, 즉 암호문이나 AAD가 변조됐다는 뜻이므로 그 평문은 한 바이트도 쓰면 안 됩니다. 스트리밍으로 복호화하면서 태그 확인 전에 평문을 다음 단계로 넘기는 구조도 같은 이유로 위험합니다.
SHA-256 해시 (파일·메시지 무결성)
#include <openssl/evp.h>
#include <cstdint>
#include <vector>
#include <stdexcept>
std::vector<uint8_t> sha256(const uint8_t* data, size_t len) {
std::vector<uint8_t> out(EVP_MAX_MD_SIZE);
unsigned int out_len = 0;
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
if (!ctx) throw std::runtime_error("EVP_MD_CTX_new failed");
if (EVP_DigestInit_ex(ctx, EVP_sha256(), nullptr) != 1 ||
EVP_DigestUpdate(ctx, data, len) != 1 ||
EVP_DigestFinal_ex(ctx, out.data(), &out_len) != 1) {
EVP_MD_CTX_free(ctx);
throw std::runtime_error("SHA256 failed");
}
EVP_MD_CTX_free(ctx);
out.resize(out_len);
return out;
}
해시는 우연한 손상을 찾는 데는 충분하지만, 공격자가 데이터와 해시를 함께 바꿀 수 있는 상황에서는 무결성을 보장하지 못합니다. 그럴 때는 키가 들어가는 HMAC이나 서명을 써야 합니다. 비밀번호 저장에 SHA-256을 그대로 쓰는 것도 너무 빨라서 무차별 대입에 약하므로, 아래의 PBKDF2나 Argon2 같은 전용 함수를 씁니다.
C++ 코드에서 흔한 보안 취약점
취약점 1: ECB 모드 사용
AES-ECB는 같은 평문 블록을 항상 같은 암호문 블록으로 바꾸므로 데이터의 패턴이 그대로 드러납니다. 비트맵 이미지를 ECB로 암호화하면 윤곽이 보이는 예가 유명합니다.
// ❌ 위험: ECB 모드
EVP_EncryptInit_ex(ctx, EVP_aes_256_ecb(), ...);
// ✅ 권장: GCM, ChaCha20-Poly1305 같은 AEAD
EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), ...);
CBC를 써야 한다면 암호화 후 HMAC을 붙이는 encrypt-then-MAC 구성으로 무결성을 따로 보장해야 합니다.
취약점 2: IV/Nonce 재사용
GCM에서 같은 키와 IV로 두 번 암호화하면 두 평문의 XOR이 드러나고 인증 키까지 노출될 수 있습니다.
// ❌ 위험: 고정 IV
uint8_t iv[12] = {0};
// ✅ 권장: 암호화마다 새 IV 생성
std::vector<uint8_t> iv(12);
if (RAND_bytes(iv.data(), 12) != 1) { /* 실패 처리 */ }
취약점 3: 패딩 오라클 (CBC)
CBC와 PKCS#7 패딩 조합에서, 복호화할 때 “패딩 오류”와 “그 밖의 오류”를 구분할 수 있게 응답하면(에러 메시지든 응답 시간이든) 공격자가 암호문을 조금씩 바꿔 보내며 평문을 한 바이트씩 알아낼 수 있습니다. GCM 같은 AEAD는 복호화 전에 전체 태그를 확인하므로 이 공격이 성립하지 않습니다.
// ✅ 권장: GCM 등 AEAD 사용으로 패딩 오라클 제거
취약점 4: 비교 시 타이밍 공격
memcmp는 다른 바이트를 만나는 순간 반환하므로, 앞부분이 많이 맞을수록 시간이 조금 더 걸립니다. 서명이나 토큰을 이렇게 비교하면 응답 시간을 재서 올바른 값을 한 바이트씩 추측할 수 있습니다.
// ❌ 위험
if (memcmp(computed, received, len) != 0) return false;
// ✅ 안전
if (CRYPTO_memcmp(computed, received, len) != 0) return false;
취약점 5: SSL_VERIFY_NONE
인증서 검증을 끄면 누구든 자신이 그 서버라고 주장할 수 있어 중간자 공격에 그대로 노출됩니다. 개발 중 자체 서명 인증서 때문에 잠시 꺼 둔 설정이 그대로 배포되는 경우가 흔합니다. 개발 환경에서는 검증을 끄지 말고 테스트용 CA를 신뢰 저장소에 추가합니다.
// ❌ 절대 사용 금지
SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, nullptr);
// ✅ 필수 (호스트 이름 검증도 함께)
SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, nullptr);
OpenSSL 반환값 검사, RAII, 키 보관 시간 최소화
모든 OpenSSL 반환값 검사
// 모든 EVP_*, SSL_*, RAND_* 등 반환값 확인
if (EVP_EncryptUpdate(ctx, out, &len, in, in_len) != 1) {
// 에러 처리, 리소스 정리, 반환
}
OpenSSL 함수는 성공 값이 함수마다 다르다는 점이 함정입니다. 대부분 1이 성공이지만, SSL_read/SSL_write처럼 바이트 수를 돌려주거나 SSL_get_error로 해석해야 하는 함수도 있습니다. 매뉴얼의 RETURN VALUES 항목을 확인하는 습관을 들이는 것이 좋습니다.
RAII로 리소스 관리
UniqueEVP_CIPHER_CTX ctx(EVP_CIPHER_CTX_new());
if (!ctx) return -1;
// 예외 발생 시에도 EVP_CIPHER_CTX_free 자동 호출
키는 최소 권한·최소 시간만 보관
{
std::vector<uint8_t> key = load_key_from_secure_storage();
do_encryption(key);
secure_zero(key.data(), key.size());
} // key 소멸
알고리즘·버전 명시
// ✅ 명시적: TLS 1.2 이상, 강한 암호 스위트만
SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION);
SSL_CTX_set_ciphersuites(ctx, "TLS_AES_256_GCM_SHA384:...");
로깅 시 민감 정보 제외
// ❌ 키/비밀/토큰 로깅 금지
// LOG("key=" << key);
// ✅ 에러 코드·상태만 로깅
LOG("EVP_EncryptUpdate failed, err=" << ERR_get_error());
보안 초기화, 키 로테이션, PBKDF2 키 파생
보안 초기화 플로우
flowchart TD
A[프로그램 시작] --> B[OPENSSL_init_ssl]
B --> C[초기화 성공?]
C -->|No| D[로그 후 종료]
C -->|Yes| E[SSL_CTX 생성]
E --> F[verify=PEER, min=TLS1.2]
F --> G[암호 스위트 제한]
G --> H[CA 저장소 로드]
H --> I[서비스 준비 완료]
키 로테이션 패턴
class KeyManager {
public:
std::vector<uint8_t> get_current_key() const {
std::lock_guard<std::mutex> lock(mutex_);
return current_key_;
}
void rotate_key(const std::vector<uint8_t>& new_key) {
std::lock_guard<std::mutex> lock(mutex_);
secure_zero(current_key_.data(), current_key_.size());
current_key_ = new_key;
}
private:
mutable std::mutex mutex_;
std::vector<uint8_t> current_key_;
};
get_current_key()는 키 복사본을 돌려주므로, 호출한 쪽도 사용이 끝나면 복사본을 지워야 합니다. 실제 키 로테이션에서는 이전 키로 암호화된 데이터를 복호화할 수 있도록 키 ID와 함께 여러 키를 보관하는 구조가 필요하고, 가능하면 키 자체는 KMS나 HSM에 두고 애플리케이션은 사용만 요청하는 편이 안전합니다.
에러 처리 및 로깅 패턴
enum class CryptoResult {
Ok,
InitFailed,
EncryptFailed,
DecryptFailed,
};
CryptoResult encrypt_with_logging(const std::vector<uint8_t>& plaintext,
std::vector<uint8_t>& ciphertext) {
UniqueEVP_CIPHER_CTX ctx(EVP_CIPHER_CTX_new());
if (!ctx) {
LOG_ERROR("EVP_CIPHER_CTX_new failed: " << get_openssl_errors());
return CryptoResult::InitFailed;
}
// ... 암호화 로직, 각 단계에서 실패 시 로그 후 반환
return CryptoResult::Ok;
}
복호화 실패를 외부 응답으로 돌려줄 때는 원인(태그 불일치, 형식 오류 등)을 구분하지 말고 하나의 에러로 합치는 편이 안전합니다. 원인을 구분해 주면 앞의 패딩 오라클처럼 공격의 단서가 됩니다. 자세한 원인은 서버 로그에만 남깁니다.
환경별 설정 분리
// 개발: 로컬 CA, 디버그 로깅
// 스테이징: 테스트 CA, 상세 로깅
// 프로덕션: 시스템 CA, 에러만 로깅, 민감 정보 절대 로깅 안 함
TLS 1.2 핸드셰이크 흐름
sequenceDiagram
participant C as Client
participant S as Server
C->>S: ClientHello (지원 버전, cipher suites)
S->>C: ServerHello, Certificate, ServerHelloDone
C->>C: 인증서 검증 (SSL_VERIFY_PEER + 호스트 이름)
C->>S: ClientKeyExchange, ChangeCipherSpec, Finished
S->>C: ChangeCipherSpec, Finished
Note over C,S: 암호화된 애플리케이션 데이터
위는 TLS 1.2의 전체 핸드셰이크입니다. TLS 1.3에서는 키 교환 값이 ClientHello와 ServerHello에 함께 실리고, 서버 인증서도 암호화된 상태로 전달되어 왕복이 한 번 줄어듭니다. 어느 버전이든 클라이언트가 인증서와 호스트 이름을 검증하는 단계는 같습니다.
키 파생 (PBKDF2)
비밀번호에서 암호화 키를 만들 때는 salt와 충분한 반복 횟수가 필수입니다. salt는 같은 비밀번호가 같은 키로 이어지지 않게 하고, 반복 횟수는 추측 한 번에 드는 비용을 높여 무차별 대입을 느리게 만듭니다.
#include <openssl/evp.h>
#include <openssl/rand.h>
#include <cstdint>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
std::vector<uint8_t> pbkdf2_sha256(const char* password, size_t pass_len,
const uint8_t* salt, size_t salt_len,
int iterations, size_t key_len) {
std::vector<uint8_t> key(key_len);
if (PKCS5_PBKDF2_HMAC(password, static_cast<int>(pass_len), salt,
static_cast<int>(salt_len), iterations, EVP_sha256(),
static_cast<int>(key_len), key.data()) != 1) {
throw std::runtime_error("PBKDF2 failed");
}
return key;
}
// 사용 예: 비밀번호 + 랜덤 salt로 32바이트 키 파생
// salt를 함께 돌려줘야 나중에 같은 키를 다시 만들 수 있음
std::pair<std::vector<uint8_t>, std::vector<uint8_t>> // {salt, key}
derive_key_from_password(const std::string& password, int iterations = 600000) {
std::vector<uint8_t> salt(16);
if (RAND_bytes(salt.data(), 16) != 1)
throw std::runtime_error("RAND_bytes failed");
auto key = pbkdf2_sha256(password.data(), password.size(),
salt.data(), salt.size(), iterations, 32);
return {salt, key};
}
salt는 비밀이 아니므로 암호문과 함께 저장하고, 사용자나 레코드마다 새로 만들어야 합니다. salt를 저장하지 않으면 같은 비밀번호로도 키를 다시 만들 수 없습니다. 반복 횟수는 하드웨어가 빨라질수록 올려야 하는 값으로, OWASP는 현재 PBKDF2-HMAC-SHA256에 600,000회 이상을 권장합니다. 새로 설계하는 비밀번호 저장이라면 GPU 공격에 더 강한 Argon2id나 scrypt를 먼저 검토합니다.
EVP 실패, 인증서 검증 실패, GCM 태그 생략, OpenSSL 3 legacy
문제 1: “EVP_EncryptUpdate failed” 또는 0 반환
키나 IV 길이가 잘못됐거나, 컨텍스트 초기화가 실패했는데 그대로 진행했거나, 출력 버퍼가 부족한 경우가 대부분입니다.
// 키 길이 확인 (AES-256 = 32바이트)
if (key.size() != 32) {
throw std::invalid_argument("AES-256 requires 32-byte key");
}
// 출력 버퍼: plain_len + block_size(16) 이상
out.resize(plain_len + 16);
int out_len = 0;
int ret = EVP_EncryptUpdate(ctx, out.data(), &out_len, in, in_len);
if (ret != 1) {
std::cerr << get_openssl_errors() << "\n";
return -1;
}
문제 2: “SSL_connect failed” / 인증서 검증 실패
CA 저장소 경로가 잘못됐거나, 인증서가 만료됐거나 자체 서명이거나, 호스트 이름이 일치하지 않는 경우입니다. SSL_get_verify_result(ssl)과 X509_verify_cert_error_string()으로 구체적인 이유를 확인할 수 있습니다.
// 시스템 CA 사용
SSL_CTX_set_default_verify_paths(ctx);
// 또는 명시적 CA 파일
SSL_CTX_load_verify_locations(ctx, "/etc/ssl/certs/ca-certificates.crt", nullptr);
// 호스트명 검증 (OpenSSL 1.1.0+)
SSL_set1_host(ssl, "example.com");
컨테이너 이미지에서만 검증이 실패한다면 경량 베이스 이미지에 ca-certificates 패키지가 없는 경우가 많습니다.
문제 3: RAND_bytes 실패
드물지만 /dev/urandom이나 getrandom()에 접근할 수 없는 샌드박스나 부팅 직후 환경에서 생길 수 있습니다.
if (RAND_bytes(buf, len) != 1) {
// RAND_status()로 상태 확인
if (RAND_status() != 1) {
// 난수 생성기가 충분히 시드되지 않음
}
throw std::runtime_error("RAND_bytes failed");
}
난수 생성이 실패했을 때 약한 난수로 대신하는 폴백은 절대 넣지 말고, 작업 자체를 실패시킵니다.
문제 4: 메모리 누수 (EVP_*, SSL_CTX 미해제)
에러 경로에서 _free 호출을 빠뜨리는 것이 주원인입니다. RAII 래퍼를 쓰면 모든 경로에서 자동으로 해제됩니다.
UniqueEVP_CIPHER_CTX ctx(EVP_CIPHER_CTX_new());
if (!ctx) return -1;
// 중간에 return/throw 해도 소멸자에서 free
문제 5: GCM 태그 검증 생략
태그를 설정하지 않거나 EVP_DecryptFinal_ex의 반환값을 무시하면, 변조된 암호문도 그럴듯한 평문으로 복호화되어 그대로 쓰이게 됩니다.
// DecryptFinal 전에 반드시 태그 설정, Final 결과로 검증
EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, 16, tag_from_ciphertext);
if (EVP_DecryptFinal_ex(ctx, out + out_len, &final_len) != 1) {
// 태그 불일치 = 변조됨
return -1;
}
문제 6: OpenSSL 3.x에서 “legacy” 알고리즘 오류
OpenSSL 3.0부터 MD4, RC4, 단일 DES, Blowfish 같은 오래된 알고리즘이 기본 프로바이더에서 빠지고 legacy 프로바이더로 옮겨졌습니다. 오래된 PKCS#12 파일을 읽거나 레거시 시스템과 통신할 때 “unsupported” 에러가 나는 이유입니다. MD5와 3DES는 아직 기본 프로바이더에 남아 있습니다.
// 필요 시 (레거시 호환용) 프로바이더 로드
#include <openssl/provider.h>
OSSL_PROVIDER* leg = OSSL_PROVIDER_load(nullptr, "legacy");
OSSL_PROVIDER* def = OSSL_PROVIDER_load(nullptr, "default"); // legacy를 명시 로드하면 default도 직접 로드해야 함
// 사용 후 OSSL_PROVIDER_unload(leg); OSSL_PROVIDER_unload(def);
legacy 프로바이더는 기존 데이터를 읽어 현대 알고리즘으로 옮기는 동안만 쓰고, 새로 만드는 데이터에는 SHA-256, AES-GCM 같은 알고리즘을 씁니다.
문제 7: 스레드 안전성
OpenSSL 1.1.0부터는 라이브러리 자체가 스레드 안전하고, 에러 큐는 스레드별로 분리되어 있어 각 스레드에서 ERR_get_error()를 부르면 자기 스레드의 에러만 나옵니다. 하나의 SSL_CTX를 여러 스레드가 공유해 SSL 객체를 만드는 것은 안전하지만, 연결이 만들어지기 시작한 뒤에 SSL_CTX 설정을 바꾸는 것은 안전하지 않습니다. 개별 SSL 객체나 EVP_CIPHER_CTX는 한 번에 한 스레드만 써야 합니다.
같이 보면 좋은 글
- C++ TLS 통신
- C++ vs Rust: 소유권, 메모리 안전성, 에러 처리, 동시성, 성능 비교
- unique_ptr 고급 활용
- constexpr 기초부터 C++26까지
- C++ 고성능 RPC 시스템: gRPC와 Protocol Buffers를 이용한 마이크로서비스 구축
- C++ constexpr 고급 가이드 | constexpr 컨테이너·알고리즘·문자열·new/delete 실전
- C++ Observability: Prometheus와 Grafana로 C++ 서버 모니터링 구축하기
- C++ 제약된 환경에서의 C++: Exception과 RTTI 없이 안전한 코드 짜기 [#42-1]
자주 묻는 질문 (FAQ)
Q. AES-GCM에서 같은 키로 IV(nonce)를 재사용하면 왜 위험한가요?
GCM은 키와 nonce로 만든 키스트림을 평문에 XOR하는 방식이라, 같은 키와 nonce를 두 번 쓰면 두 암호문을 XOR해서 평문끼리의 관계가 드러납니다. 게다가 인증 태그를 만드는 값도 노출될 수 있어, 공격자가 태그를 위조할 수 있는 상황까지 이어집니다. nonce는 메시지마다 RAND_bytes 같은 암호학적 난수로 새로 만들거나 절대 겹치지 않는 카운터로 관리하고, 복호화 시에는 태그 검증 결과를 반드시 확인해야 합니다.
Q. OpenSSL 대신 다른 라이브러리는?
구글이 OpenSSL에서 분기한 BoringSSL, 잘못 쓰기 어렵게 설계된 고수준 API를 제공하는 libsodium, 임베디드 환경을 겨냥한 Mbed TLS 등이 있습니다. 대칭 암호화나 서명처럼 목적이 분명하다면 알고리즘 선택을 라이브러리가 대신해 주는 libsodium이 실수할 여지가 적습니다. TLS 스택까지 필요하고 기존 생태계와의 호환이 중요하다면 OpenSSL이 여전히 가장 무난한 선택입니다.