32. 수신 프레임 조립 버퍼의 최대 크기 상한 부재
난이도 중해설 — 수신 프레임 조립 버퍼의 최대 크기 상한 부재
난이도: 중상
요약
SessionRecvBuffer 는 소켓에서 읽은 바이트를 세션별 누적 버퍼(A)에 무조건 이어붙이고, 프레임 길이 프리픽스(declaredLen)를 검증 없이 그대로 신뢰해 그 값만큼 바이트가 모일 때까지 계속 기다린다(B). 정상 클라이언트를 가정하면 문제없이 동작하지만, 프레임을 끝까지 보내지 않는 세션이나 비정상적으로 큰 declaredLen 을 주장하는 세션에 대해서는 아무 상한도 걸려 있지 않다. 세션 수만큼 이런 누적 버퍼가 무제한으로 자랄 수 있어, 단일 세션 하나만으로도(또는 소수의 세션으로도) 서버 프로세스 전체의 메모리를 고갈시킬 수 있다.
문제점
- 분류: 리소스 상한 부재(unbounded resource growth) / 입력 검증 부재
- 증상: 악의적이거나 오작동하는 클라이언트가 프레임의 앞부분(큰
declaredLen을 담은 길이 프리픽스)만 보내고 나머지 페이로드를 아주 느리게 또는 전혀 보내지 않으면, 서버는 해당 세션의_accum리스트를 계속 키우며 그 프레임이 "완성"되기만을 기다린다. 여러 연결에서 동시에 이런 패턴을 반복하면 서버 프로세스의 메모리 사용량이 계속 증가해 결국 OOM 이나 GC 압박으로 다른 정상 세션의 처리 지연/장애까지 유발할 수 있다. - 재현 조건: 클라이언트가
[4바이트 길이 프리픽스 = 매우 큰 값(예: int.MaxValue 근처)][그 뒤로는 페이로드를 조금만 보내거나 아예 보내지 않음]을 전송하고 연결을 계속 유지. - 근본 원인:
- (A)
OnBytesReceived가 현재까지 누적된 바이트 수를 전혀 확인하지 않고 무조건 이어붙인다. 세션당 누적 가능한 최대 바이트 수에 대한 정책이 코드 어디에도 없다. - (B)
declaredLen값 자체에 대한 유효성 검사(최대 허용 프레임 크기와 비교)가 없다. 프로토콜상 정상적인 프레임이 아무리 커도 특정 상한(예: 64KB)을 넘지 않는다는 사실을 서버가 강제하지 않으므로, 클라이언트가 주장하는 임의의 길이를 그대로 신뢹한다.
- (A)
수정안
프레임 최대 크기 상한을 두고, 길이 프리픽스가 이를 초과하면 즉시 연결을 거부/종료한다. 또한 누적 버퍼 자체의 최대 크기도 별도로 제한해 이중으로 방어한다.
using System;
using System.Collections.Generic;
public sealed class FrameTooLargeException : Exception
{
public FrameTooLargeException(string message) : base(message) { }
}
public sealed class SessionRecvBuffer
{
// 프로토콜상 허용하는 최대 프레임 크기(페이로드 기준). 실제 값은
// 서비스의 최대 메시지 크기 요구사항에 맞춰 정한다.
private const int MaxFrameSize = 64 * 1024;
// 누적 버퍼 자체의 최대 크기(길이 프리픽스 포함). 프레임이 아직
// 완전히 파싱되지 않은 상태에서도 무제한으로 커지지 않도록 방어한다.
private const int MaxAccumSize = MaxFrameSize + 4;
private readonly List<byte> _accum = new();
public void OnBytesReceived(byte[] chunk, int length)
{
if (_accum.Count + length > MaxAccumSize)
{
// 상한을 넘어서면 더 이상 누적하지 않고 세션을 종료시켜야 한다.
// (연결 종료 처리는 호출자/세션 매니저 책임으로 위임)
throw new FrameTooLargeException(
$"receive buffer exceeded limit: {_accum.Count + length} > {MaxAccumSize}");
}
for (int i = 0; i < length; i++)
_accum.Add(chunk[i]);
}
public bool TryExtractFrame(out byte[] payload)
{
payload = null;
if (_accum.Count < 4)
return false;
int declaredLen = BitConverter.ToInt32(new[] { _accum[0], _accum[1], _accum[2], _accum[3] }, 0);
// 길이 프리픽스 자체를 검증한다: 음수이거나 허용 상한을 넘으면
// 프로토콜 위반으로 간주하고 즉시 거부한다(더 기다리지 않는다).
if (declaredLen < 0 || declaredLen > MaxFrameSize)
{
throw new FrameTooLargeException(
$"declared frame length out of bounds: {declaredLen} (max={MaxFrameSize})");
}
if (_accum.Count < 4 + declaredLen)
return false; // 아직 다 도착하지 않음 — 더 기다린다(상한 이내이므로 안전).
payload = new byte[declaredLen];
for (int i = 0; i < declaredLen; i++)
payload[i] = _accum[4 + i];
_accum.RemoveRange(0, 4 + declaredLen);
return true;
}
}
호출부(세션 수신 루프)는 FrameTooLargeException 을 잡아 해당 세션을 즉시 종료(연결 끊기)하도록 처리해야 한다 — 예외를 삼키고 계속 누적을 허용하면 상한 검사가 무의미해진다.
더 나은 설계
- 길이 프리픽스 검증을 가장 먼저(채택안): 4바이트를 읽자마자
declaredLen을 검증해, 페이로드를 단 1바이트도 누적하기 전에 거부할 수 있다. 위 수정안처럼 페이로드 상한과 누적 버퍼 상한을 함께 두면, 길이 필드 자체가 조작되어도(또는 아직 4바이트조차 다 도착하지 않은 상태에서 청크가 계속 들어와도) 이중으로 방어된다. - 세션별 누적 바이트에 대한 전역 워터마크/레이트리밋: 상한을 넘은 세션은 즉시 끊는 것 외에, 접속 초기 일정 시간 내에 반복적으로 이런 패턴을 보이는 클라이언트(IP)는 재접속 자체를 제한하는 방어를 추가하면 slowloris 류의 반복 공격에도 대응할 수 있다.
List<byte>대신 growable ring buffer /ArrayPool<byte>기반 버퍼: 현재 구현은List<byte>.Add를 바이트 단위로 호출해 오버헤드가 크고,RemoveRange도 앞부분 시프트 비용이 든다. 상한을 두는 것과 별개로, 성능 관점에서는 청크 단위Array.Copy/링버퍼로 재작성하는 편이 낫다. 다만 이는 이번 결함(무제한 누적)과는 별개의 최적화 트랙이다.- 트레이드오프: 상한을 너무 낮게 잡으면 정상적인 대용량 메시지(예: 대규모 인벤토리 동기화)가 프레임 하나로 못 들어가 별도의 분할 전송 프로토콜이 필요해진다. 상한 값은 실제 서비스의 최대 정상 메시지 크기를 기준으로 여유를 두고 정해야 한다.
면접 포인트
- 길이 프리픽스 프로토콜을 구현할 때는 "길이 필드 값 자체를 신뢰하지 않는다"는 원칙이 기본이다 — 파싱 전 반드시 상한과 비교해 프로토콜/애플리케이션 레벨의 최대 메시지 크기를 강제해야 한다.
- "아직 다 도착하지 않았으니 기다린다"는 로직은 필연적으로 무제한 대기를 허용하는 함정이 있다 — 대기 자체를 막을 수는 없어도, 대기 중 누적되는 리소스(메모리)에는 반드시 상한이 있어야 한다는 것을 구분해서 설명할 수 있어야 한다.
- 단일 세션의 리소스 사용을 제한하는 것은 "정상 사용자 보호"와 "악의적/오작동 사용자로부터 서버 전체 보호"를 동시에 달성하는 방어적 설계의 기본이며, 이런 상한은 네트워크 계층뿐 아니라 큐 길이, 캐시 크기 등 서버의 다른 무제한 성장 지점에도 동일하게 적용해야 하는 패턴이다.
해설 — 수신 프레임 조립 버퍼의 최대 크기 상한 부재
난이도: 중상
요약
SessionRecvBuffer 는 recv 로 읽은 바이트를 누적 버퍼(A)에 무조건 이어붙이고, 길이 프리픽스(declaredLen)를 검증 없이 그대로 신뢰해 그 값만큼 바이트가 모일 때까지 계속 기다린다(B). 프레임을 끝까지 보내지 않는 세션이나 비정상적으로 큰 declaredLen 을 주장하는 세션에 대해 아무 상한도 없어, std::vector<uint8_t> accum_ 가 세션당 무제한으로 재할당·증가할 수 있다. C++ 서버는 이런 벡터 증가가 힙 단편화와 직결되므로, 단순 메모리 고갈뿐 아니라 장시간 운영 시 힙 단편화로 인한 성능 저하까지 함께 겪을 수 있다.
문제점
- 분류: 리소스 상한 부재(unbounded resource growth) / 입력 검증 부재
- 증상: 악의적이거나 오작동하는 클라이언트가 큰
declaredLen을 담은 길이 프리픽스만 보내고 나머지 페이로드를 느리게 또는 전혀 보내지 않으면, 서버는 해당 세션의accum_를 계속 키우며 프레임이 "완성"되기를 기다린다. 여러 연결이 동시에 이런 패턴을 반복하면 프로세스 메모리가 계속 증가해 결국std::bad_alloc또는 OOM killer 에 의한 강제 종료로 이어질 수 있고, 그 과정에서 반복적인vector재할당은 힙을 단편화시켜 다른 세션의 할당 실패 가능성도 높인다. - 재현 조건: 클라이언트가
[4바이트 길이 프리픽스 = 매우 큰 값][페이로드는 조금만 전송하거나 전혀 전송하지 않음]을 보내고 연결을 유지. - 근본 원인:
- (A)
OnBytesReceived가 현재까지 누적된 바이트 수를 확인하지 않고 무조건insert한다. 세션당 누적 가능한 최대 바이트 수에 대한 정책이 없다. - (B)
declaredLen값에 대한 유효성 검사(최대 허용 프레임 크기와 비교)가 없다.uint32_t로 읽은 값이 서버가 실제로 감당 가능한 크기를 훨씬 초과해도 그대로 신뢰하고 대기를 계속한다.
- (A)
수정안
프레임 최대 크기 상한을 두고, 길이 프리픽스가 이를 초과하면 예외(또는 에러 코드)로 즉시 세션 종료를 유도한다. 누적 버퍼 자체의 최대 크기도 별도로 제한해 이중 방어한다.
#include <cstdint>
#include <cstring>
#include <stdexcept>
#include <vector>
class FrameTooLargeError : public std::runtime_error
{
public:
explicit FrameTooLargeError(const std::string& msg) : std::runtime_error(msg) {}
};
class SessionRecvBuffer
{
public:
// 프로토콜상 허용하는 최대 프레임 크기(페이로드 기준).
static constexpr size_t kMaxFrameSize = 64 * 1024;
// 누적 버퍼 자체의 최대 크기(길이 프리픽스 포함).
static constexpr size_t kMaxAccumSize = kMaxFrameSize + 4;
void OnBytesReceived(const uint8_t* chunk, size_t length)
{
if (accum_.size() + length > kMaxAccumSize)
{
// 상한 초과 — 더 누적하지 않는다. 호출자가 이 예외를 받아
// 해당 세션 연결을 즉시 종료해야 한다.
throw FrameTooLargeError(
"receive buffer exceeded limit: " +
std::to_string(accum_.size() + length) + " > " + std::to_string(kMaxAccumSize));
}
accum_.insert(accum_.end(), chunk, chunk + length);
}
bool TryExtractFrame(std::vector<uint8_t>& payload)
{
if (accum_.size() < 4)
return false;
uint32_t declaredLen;
std::memcpy(&declaredLen, accum_.data(), sizeof(declaredLen));
// 길이 프리픽스 자체를 검증한다: 허용 상한을 넘으면 프로토콜
// 위반으로 간주하고 즉시 거부한다(더 기다리지 않는다).
if (declaredLen > kMaxFrameSize)
{
throw FrameTooLargeError(
"declared frame length out of bounds: " + std::to_string(declaredLen) +
" (max=" + std::to_string(kMaxFrameSize) + ")");
}
if (accum_.size() < 4 + static_cast<size_t>(declaredLen))
return false; // 아직 다 도착하지 않음 — 상한 이내이므로 계속 기다려도 안전.
payload.assign(accum_.begin() + 4, accum_.begin() + 4 + declaredLen);
accum_.erase(accum_.begin(), accum_.begin() + 4 + declaredLen);
return true;
}
private:
std::vector<uint8_t> accum_;
};
호출부(세션 수신 루프)는 FrameTooLargeError 를 잡아 해당 소켓을 즉시 닫고 세션을 정리해야 한다 — 예외를 삼키고 계속 누적을 허용하면 상한 검사가 무의미해진다.
더 나은 설계
- 길이 프리픽스 검증을 가장 먼저(채택안): 4바이트를 읽는 즉시
declaredLen을 검증해, 페이로드를 1바이트도 누적하기 전에 거부할 수 있다. 페이로드 상한과 누적 버퍼 상한을 함께 두면 길이 필드 조작과 느린 분할 전송 모두에 대해 이중으로 방어된다. - 고정 크기 링버퍼로 교체:
std::vector의 반복적인insert/erase(begin, ...)는 앞부분 원소를 시프트하는 비용(O(n))이 들고, 재할당 시 힙 단편화를 유발한다. 상한이 이미 정해져 있다면kMaxAccumSize크기의 고정 링버퍼(원형 인덱스로 읽기/쓰기 위치 관리)로 바꾸면 재할당 자체가 없어져 성능과 메모리 예측 가능성이 모두 좋아진다. 다만 구현이vector보다 복잡해지고 프레임이 버퍼 경계를 넘어갈 때의 랩어라운드 처리가 필요하다. - 세션 매니저 레벨의 전역 메모리 워터마크: 개별 세션 상한과 별개로, 서버 전체 수신 버퍼 총합에 대한 워터마크를 두고 임계치 초과 시 신규 접속을 거부하거나 기존 유휴 세션을 우선 정리하는 방어를 추가하면, 다수의 세션이 각각은 상한 이내지만 합쳐서 문제가 되는 경우까지 방어할 수 있다.
- 트레이드오프: 링버퍼는 성능과 메모리 예측성이 좋지만 구현·디버깅 복잡도가 올라간다. 트래픽 패턴이 상한을 자주 근접하게 사용하지 않는 일반적인 게임 서버라면, 우선
vector+ 상한 검사로 안전성부터 확보하고, 프로파일링 결과 병목이 확인되면 링버퍼로 교체하는 단계적 접근이 실용적이다.
면접 포인트
- 길이 프리픽스 프로토콜에서는 "길이 필드 값 자체를 신뢰하지 않는다"는 원칙이 기본이며, 파싱 전 반드시 상한과 비교해 애플리케이션 레벨 최대 메시지 크기를 강제해야 한다.
- "아직 다 도착하지 않았으니 기다린다"는 로직은 대기 자체를 막을 수 없어도, 대기 중 누적되는 메모리 자원에는 반드시 상한이 있어야 한다 — 이 구분을 명확히 설명할 수 있어야 한다.
- C++ 에서
std::vector의 반복적 삽입/삭제로 인한 힙 단편화·재할당 비용까지 고려한 자료구조 선택(링버퍼 등)은, 단순히 "상한을 둔다"는 논리적 수정에서 한 걸음 더 나아간 성능/운영 관점의 개선이라는 점을 구분해서 이야기할 수 있어야 한다.