← 문제로

32. 수신 프레임 조립 버퍼의 최대 크기 상한 부재

난이도 중
내 리뷰 · C#
해설 · C#

해설 — 수신 프레임 조립 버퍼의 최대 크기 상한 부재

난이도: 중상

요약

SessionRecvBuffer 는 소켓에서 읽은 바이트를 세션별 누적 버퍼(A)에 무조건 이어붙이고, 프레임 길이 프리픽스(declaredLen)를 검증 없이 그대로 신뢰해 그 값만큼 바이트가 모일 때까지 계속 기다린다(B). 정상 클라이언트를 가정하면 문제없이 동작하지만, 프레임을 끝까지 보내지 않는 세션이나 비정상적으로 큰 declaredLen 을 주장하는 세션에 대해서는 아무 상한도 걸려 있지 않다. 세션 수만큼 이런 누적 버퍼가 무제한으로 자랄 수 있어, 단일 세션 하나만으로도(또는 소수의 세션으로도) 서버 프로세스 전체의 메모리를 고갈시킬 수 있다.

문제점

  • 분류: 리소스 상한 부재(unbounded resource growth) / 입력 검증 부재
  • 증상: 악의적이거나 오작동하는 클라이언트가 프레임의 앞부분(큰 declaredLen 을 담은 길이 프리픽스)만 보내고 나머지 페이로드를 아주 느리게 또는 전혀 보내지 않으면, 서버는 해당 세션의 _accum 리스트를 계속 키우며 그 프레임이 "완성"되기만을 기다린다. 여러 연결에서 동시에 이런 패턴을 반복하면 서버 프로세스의 메모리 사용량이 계속 증가해 결국 OOM 이나 GC 압박으로 다른 정상 세션의 처리 지연/장애까지 유발할 수 있다.
  • 재현 조건: 클라이언트가 [4바이트 길이 프리픽스 = 매우 큰 값(예: int.MaxValue 근처)][그 뒤로는 페이로드를 조금만 보내거나 아예 보내지 않음] 을 전송하고 연결을 계속 유지.
  • 근본 원인:
    • (A) OnBytesReceived 가 현재까지 누적된 바이트 수를 전혀 확인하지 않고 무조건 이어붙인다. 세션당 누적 가능한 최대 바이트 수에 대한 정책이 코드 어디에도 없다.
    • (B) declaredLen 값 자체에 대한 유효성 검사(최대 허용 프레임 크기와 비교)가 없다. 프로토콜상 정상적인 프레임이 아무리 커도 특정 상한(예: 64KB)을 넘지 않는다는 사실을 서버가 강제하지 않으므로, 클라이언트가 주장하는 임의의 길이를 그대로 신뢹한다.

수정안

프레임 최대 크기 상한을 두고, 길이 프리픽스가 이를 초과하면 즉시 연결을 거부/종료한다. 또한 누적 버퍼 자체의 최대 크기도 별도로 제한해 이중으로 방어한다.

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/링버퍼로 재작성하는 편이 낫다. 다만 이는 이번 결함(무제한 누적)과는 별개의 최적화 트랙이다.
  • 트레이드오프: 상한을 너무 낮게 잡으면 정상적인 대용량 메시지(예: 대규모 인벤토리 동기화)가 프레임 하나로 못 들어가 별도의 분할 전송 프로토콜이 필요해진다. 상한 값은 실제 서비스의 최대 정상 메시지 크기를 기준으로 여유를 두고 정해야 한다.

면접 포인트

  • 길이 프리픽스 프로토콜을 구현할 때는 "길이 필드 값 자체를 신뢰하지 않는다"는 원칙이 기본이다 — 파싱 전 반드시 상한과 비교해 프로토콜/애플리케이션 레벨의 최대 메시지 크기를 강제해야 한다.
  • "아직 다 도착하지 않았으니 기다린다"는 로직은 필연적으로 무제한 대기를 허용하는 함정이 있다 — 대기 자체를 막을 수는 없어도, 대기 중 누적되는 리소스(메모리)에는 반드시 상한이 있어야 한다는 것을 구분해서 설명할 수 있어야 한다.
  • 단일 세션의 리소스 사용을 제한하는 것은 "정상 사용자 보호"와 "악의적/오작동 사용자로부터 서버 전체 보호"를 동시에 달성하는 방어적 설계의 기본이며, 이런 상한은 네트워크 계층뿐 아니라 큐 길이, 캐시 크기 등 서버의 다른 무제한 성장 지점에도 동일하게 적용해야 하는 패턴이다.