← 문제로

1. TCP 길이 프리픽스 패킷 수신 루프

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

해설 — TCP 길이 프리픽스 패킷 수신 루프

난이도: 하

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

TCP 스트림에서 [2B 길이][payload] 패킷을 잘라내는 전형적인 수신 루프다. 파싱 골격(부분/복수 수신 루프, 잔여 바이트 당기기)은 맞지만, 누적 버퍼 오버플로우, 길이 필드 무검증, 큰 패킷 영구 정지(stuck), 핫패스 복사 네 가지가 실무에서 문제를 일으킨다. 특히 (A)는 외부 입력으로 트리거되는 버퍼 오버런이다.


문제점

(A) 누적 버퍼 오버플로우 — 보안/정확성

  • 증상: Array.Copy(data, 0, _buffer, _writePos, len)에서 _writePos + len > 4096이면 ArgumentException(혹은 네이티브 버퍼였다면 오버런).
  • 재현조건: 한 패킷이 4KB에 가깝거나, 짧은 패킷이 연달아 들어와 처리 전에 누적이 4096을 넘을 때. 공격자가 큰 길이의 패킷을 흘려보내면 의도적으로 유발 가능.
  • 근본원인: 쓰기 전에 _buffer남은 공간(free space)을 검사하지 않는다. 고정 4096 버퍼인데 경계 체크가 없다.

(C)+(B) 길이 필드 무검증 — 보안

  • 증상: payload 길이를 그대로 신뢰한다. payloadLen이 4094보다 크면 if (_writePos - readPos < 2 + payloadLen)영원히 참 → 패킷이 절대 완성되지 않고, 누적 버퍼는 (A) 때문에 곧 터지거나, 안 터져도 세션이 무한 정지(헤드 오브 라인 블로킹).
  • 재현조건: 변조 클라이언트가 payloadLen = 60000 헤더만 보냄.
  • 근본원인: 신뢰할 수 없는 길이 필드를 상한(MaxPayload)과 대조하지 않음. "길이 필드는 절대 신뢰하지 말라"는 원칙 위반.

(큰 패킷 stuck) 버퍼보다 큰 정상 패킷 처리 불가 — 정확성

  • 설령 검증을 넣어도, 단일 고정 버퍼(4096)는 버퍼보다 큰 정상 패킷(예: 인벤토리 풀 동기화 5KB)을 영원히 못 받는다. 최대 패킷 크기 정책과 버퍼 크기가 명시적으로 묶여 있어야 한다.

(D) 잔여 바이트 memmove — 성능

  • 매 콜백마다 남은 바이트를 버퍼 앞으로 Array.Copy로 당긴다. 부분 수신이 잦은 고RPS 환경에서 반복 복사 비용이 누적된다. (정확성 문제는 아니지만 핫패스 낭비.)

수정안

(A)+(B)+(C) 경계 검사 + 길이 상한 검증

private const int MaxPayload = 8192;        // 프로토콜이 허용하는 최대 payload
private readonly byte[] _buffer = new byte[2 + MaxPayload];

public bool OnReceive(byte[] data, int len)  // false 반환 시 세션 강제 종료
{
    // (A) 남은 공간 검사: 쓸 자리가 없으면 비정상
    if (_writePos + len > _buffer.Length)
        return false;                        // 누적 버퍼 초과 → 끊는다

    Array.Copy(data, 0, _buffer, _writePos, len);
    _writePos += len;

    int readPos = 0;
    while (true)
    {
        if (_writePos - readPos < 2) break;

        ushort payloadLen = (ushort)(_buffer[readPos] | (_buffer[readPos + 1] << 8));

        // (B)+(C) 길이 상한 검증 — 신뢰하지 않는다
        if (payloadLen > MaxPayload)
            return false;                    // 변조/버그 → 끊는다

        if (_writePos - readPos < 2 + payloadLen) break;  // 아직 부분 수신

        OnPacket(_buffer, readPos + 2, payloadLen);
        readPos += 2 + payloadLen;
    }

    int remain = _writePos - readPos;
    if (remain > 0 && readPos > 0)
        Array.Copy(_buffer, readPos, _buffer, 0, remain);
    _writePos = remain;
    return true;
}

(D) 링 버퍼 / 이중 인덱스로 memmove 제거

readPos/writePos를 유지하는 링 버퍼나, "버퍼 거의 다 찼을 때만 당기는" 지연 압축(compact-on-threshold)으로 매 콜백 복사를 없앤다.


더 나은 설계

  • 길이 검증 패턴: 헤더를 읽는 즉시 0 < len <= Max 범위 검사. 통과 못 하면 파싱을 멈추고 세션을 끊는다(에러 응답조차 신뢰 못 하는 입력엔 사치). 로그/메트릭 적재.
  • 버퍼 크기 = 헤더 + Max payload로 못박아, "버퍼보다 큰 패킷" 모순을 구조적으로 제거.
  • Span<byte>/Memory<byte> + ArrayPool: 핫패스에서 슬라이스만 넘기고 복사 최소화. System.IO.Pipelines는 이 패턴(부분/복수 수신, 백프레셔)을 라이브러리 차원에서 해결한다.
  • 헤더에 길이 외 식별 필드: 길이만으로는 desync 복구가 어렵다 → 매직/타입을 함께 두면 스트림이 어긋났을 때 조기에 탐지 가능.

트레이드오프

  • 링 버퍼는 빠르지만 래핑(wrap-around) 구간 처리 코드가 늘어 버그 가능성↑. 대부분은 compact-on-threshold면 충분하다.
  • 패킷마다 매직/CRC를 붙이면 대역폭이 늘지만 desync/변조 탐지력이 올라간다.

면접 포인트

  1. "TCP에서 메시지 경계가 없다는 게 무슨 뜻이고, 길이 프리픽스 외에 경계를 잡는 방법은?" → 구분자(delimiter) 방식 vs 길이 프리픽스 vs 고정 길이. 게임은 보통 길이 프리픽스.
  2. "길이 필드를 신뢰하면 왜 위험한가? 어떤 공격이 가능한가?" → 거대한 길이로 메모리 고갈/세션 정지, (네이티브라면) 버퍼 오버런으로 RCE까지.
  3. "한 콜백에 패킷 1.5개가 왔다. 0.5개는 어떻게 보관하나?" → 누적 버퍼에 잔여 바이트 유지, 다음 수신과 합쳐 다시 파싱.

내가 놓친 항목 (복습용)

  • [ ] (A) 누적 버퍼 free space 미검사 → 오버런
  • [ ] (B)(C) payload 길이 상한 미검증 → 무한 정지/메모리 고갈
  • [ ] 버퍼보다 큰 정상 패킷 처리 불가(정책-버퍼 크기 결합)
  • [ ] (D) 매 콜백 memmove 비용