← 문제로

8. 대용량 메시지 프래그먼트/재조립

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

해설 — 대용량 메시지 프래그먼트/재조립

난이도: 상

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

요약

MTU 초과 메시지를 [msgId][fragIndex][fragCount] 로 쪼개 재조립하는 코드다. "수신 카운트 == fragCount 면 완성"이라는 골격은 맞지만, 신뢰할 수 없는 프래그먼트 헤더를 검증 없이 메모리 할당/오프셋 계산에 사용해서 다수의 보안·정확성 결함이 있다. 핵심 결함은 (1) 가변 payloadLen 으로 고정 오프셋(fragIndex × payloadLen) 계산 → 재조립 깨짐/예외, (2) fragCount·fragIndex 무검증 → 거대 할당·IndexOutOfRange, (3) 재조립 상태의 수명/메모리 상한 부재 → DoS, (4) 길이/카운트 일관성 미검증이다. C++ 트윈은 OOB write 로 힙 손상/RCE 였으나, C#은 배열 경계 검사로 ArgumentException/ IndexOutOfRangeException(DoS) + OutOfMemoryException 으로 나타난다 — 메모리 손상은 막히되 가용성·정확성은 동일하게 붕괴한다.


문제점

(C)+(D) 가변 payloadLen 으로 오프셋/전체 크기 추정 — 정확성/메모리 안전

  • 증상: 마지막 프래그먼트는 보통 payload 가 짧다. 그런데 new byte[fragCount * payloadLen]offset = fragIndex * payloadLen모든 프래그먼트가 동일 크기라고 가정. 첫 프래그먼트가 마지막(짧은) 것이면 버퍼를 과소 할당 → 이후 큰 프래그먼트가 Array.CopyArgumentException(대상 범위 초과). 반대로 첫 게 가득 찬 것이면 마지막 프래그먼트가 엉뚱한 오프셋에 써져 메시지가 깨진다.
  • 재현조건: 프래그먼트가 순서대로 안 옴(신뢰 UDP 라도 재전송으로 역순 가능), 또는 변조 클라가 fragIndex 를 큰 값으로 보냄 → offset 이 버퍼 밖 → ArgumentException/ IndexOutOfRange. (C++은 OOB write 였지만 C#은 경계 검사로 예외 → 워커 크래시 DoS.)
  • 근본원인: "프래그먼트 크기가 균일"하다는 검증되지 않은 가정 + 인덱스 경계 미검사.

(B)+(C) fragCount 무검증 → 거대 할당 DoS — 보안

  • 증상: 첫 프래그먼트의 fragCountpayloadLen 을 곱해 바로 new byte[...]. 변조 클라가 fragCount=65535, payload 가 큰 프래그먼트를 보내면 수 GB 할당 시도 → OutOfMemoryException. new bool[65535] 도 함께. 한 패킷으로 메모리 고갈. 곱셈 fragCount * payloadLen 자체가 int 오버플로로 음수/작은 값이 되어 추가 오동작 가능.
  • 근본원인: 신뢰 못 할 카운트를 상한 검사 없이 할당 파라미터로 사용.

(A) 길이/헤더 일관성 미검증 — 메모리 안전

  • 증상: len < HeaderSizepayloadLen = len - HeaderSize음수가 되어 new byte[fragCount * payloadLen]/Array.Copy(..., payloadLen) 에서 예외. 또 BitConverter.ToUInt16(buf, 6) 자체가 짧은 버퍼에서 ArgumentOutOfRangeException. (C++의 size_t 언더플로(거대값)와 달리 C#은 음수지만 결국 예외로 폭발.)
  • 근본원인: 입력 최소 길이/뺄셈 가드 부재 + 호스트 엔디안 BitConverter 의존.

(B)+(E) 재조립 상태 수명·메모리 상한 부재 — DoS/정확성

  • 증상: 완성되지 않는 메시지(유실/공격)는 _pending영원히 남는다. 공격자가 서로 다른 msgId 로 "절반만" 보내면 딕셔너리가 무한 증식 → OOM. 타임아웃/개수 상한/세션별 쿼터가 전혀 없다.
  • 추가: msgId 가 세션 키 없이 전역 Dictionary 다. 다른 클라가 같은 msgId 를 보내면 서로의 재조립 버퍼를 간섭/오염(cross-session corruption)할 수 있다.
  • 근본원인: 부분 상태의 lifecycle 관리·격리·쿼터 부재.

(C)+(D) fragCount 일관성 미검증 — 정확성

  • 한 메시지의 프래그먼트마다 fragCount 가 다르게 와도 검증하지 않는다. 첫 값만 믿는다. 변조 시 got 크기와 어긋나 st.got[fragIndex]IndexOutOfRange.

수정안

헤더·인덱스·카운트 전면 검증 + 명시적 길이 헤더

// 와이어 헤더에 totalSize/fragOffset/fragLen 을 추가하는 게 정석 (균일 크기 가정 제거)
//   [uint msgId][uint totalSize][ushort fragIndex][ushort fragCount][ushort fragOffset][ushort fragLen]
private const int HeaderSize2 = 16;
private const uint kMaxMessageBytes      = 1 << 20;   // 1MB 상한 (정책)
private const ushort kMaxFragCount       = 1024;
private const int  kMaxPendingPerSession = 8;         // 동시 재조립 상한

public void OnFragment(Session sess, byte[] buf, int len)
{
    if (len < HeaderSize2) return;                                  // (A) 최소 길이

    var sp        = buf.AsSpan(0, len);
    uint   msgId      = BinaryPrimitives.ReadUInt32LittleEndian(sp);          // 엔디안 명시
    uint   totalSize  = BinaryPrimitives.ReadUInt32LittleEndian(sp.Slice(4));
    ushort fragIndex  = BinaryPrimitives.ReadUInt16LittleEndian(sp.Slice(8));
    ushort fragCount  = BinaryPrimitives.ReadUInt16LittleEndian(sp.Slice(10));
    ushort fragOffset = BinaryPrimitives.ReadUInt16LittleEndian(sp.Slice(12));
    ushort fragLen    = BinaryPrimitives.ReadUInt16LittleEndian(sp.Slice(14));

    int avail = len - HeaderSize2;

    // (B)(C) 카운트/사이즈 상한 검증
    if (fragCount == 0 || fragCount > kMaxFragCount) return;
    if (totalSize == 0 || totalSize > kMaxMessageBytes) return;
    if (fragIndex >= fragCount) return;
    if (fragLen > avail) return;
    // (D) 오프셋 경계 검증 (long 으로 오버플로 방지)
    if ((long)fragOffset + fragLen > totalSize) return;

    // 세션별 격리 + 쿼터
    var pend = sess.pending;                                        // Dictionary<uint, State>
    if (!pend.TryGetValue(msgId, out var st))
    {
        if (pend.Count >= kMaxPendingPerSession) return;            // 동시 재조립 제한
        st = new State {
            totalSize = totalSize, fragCount = fragCount,
            buffer = new byte[totalSize], got = new bool[fragCount],
            bytesReceived = 0, deadline = Now() + kReassemblyTimeout // 타임아웃
        };
        pend[msgId] = st;
    }

    // (C 일관성) 후속 프래그먼트 메타가 첫 것과 다르면 폐기
    if (st.totalSize != totalSize || st.fragCount != fragCount) { pend.Remove(msgId); return; }
    if (st.got[fragIndex]) return;                                  // 중복: 무시(idempotent)

    Array.Copy(buf, HeaderSize2, st.buffer, fragOffset, fragLen);   // 검증된 오프셋
    st.got[fragIndex] = true;
    st.bytesReceived += fragLen;

    if (st.bytesReceived == st.totalSize)                           // 카운트가 아니라 실제 바이트로
    {
        OnMessageComplete(msgId, st.buffer, st.buffer.Length);
        pend.Remove(msgId);
    }
}

완성 판정을 "received == fragCount" 가 아니라 "채워진 바이트 == totalSize"(또는 모든 got 비트 set)로 하면 균일-크기 가정에서 완전히 자유로워진다.

주기적 청소(타임아웃)

별도 타이머에서 deadline 지난 pending 항목을 제거 → 미완성 메시지 누수 방지.


더 나은 설계

  • 헤더에 totalSize/offset/len 을 명시 → "모든 프래그먼트 크기가 같다"는 위험한 암묵 가정을 제거. QUIC/IP 프래그먼트도 offset 기반.
  • 세션 단위 격리 + 쿼터: 재조립 상태는 세션에 귀속. 동시 재조립 개수, 총 메모리, 메시지 최대 크기에 상한. 초과 시 가장 오래된 것 폐기 또는 거부.
  • 타임아웃/가비지 수집: 부분 상태는 TTL 을 두고 청소. 신뢰 계층의 RTO 와 맞춤.
  • 가능하면 애플리케이션 프래그먼트를 피한다: 큰 스냅샷은 델타/스트리밍으로 쪼개고, 전송 계층(QUIC 스트림)이 프래그먼트를 처리하게 위임하면 이 클래스의 버그가 사라진다.
  • 할당 산술은 checked/long: fragCount * payloadLen 같은 곱셈은 int 오버플로를 피하도록 long 으로 계산하거나 checked 블록으로 즉시 검출.

트레이드오프

  • 헤더에 offset/len/totalSize 를 넣으면 프래그먼트당 몇 바이트 오버헤드가 늘지만, 안전성과 비균일 프래그먼트 지원을 얻는다.
  • 세션별 쿼터는 정상적인 대용량 전송을 제한할 수 있어, 상한을 콘텐츠(맵 크기)에 맞춰 넉넉히 잡되 절대 무한대는 두지 않는다.

면접 포인트

  1. "프래그먼트 재조립에서 가장 흔한 버그는? C#과 C++에서 결과가 어떻게 다른가?" → fragIndex/offset 무검증, fragCount × size 거대 할당, len 음수/언더플로. C++은 OOB write(RCE), C#은 ArgumentException/OutOfMemoryException(DoS). IP 프래그먼트 공격(teardrop, ping of death) 계보.
  2. "완성 판정을 fragCount 카운트로 하면 뭐가 문제인가?" → 균일 크기 가정·중복 카운팅·메타 불일치에 취약. 실제 채워진 바이트/비트맵으로 판정.
  3. "미완성 재조립 상태를 어떻게 관리하나?" → 세션 격리 + 동시 개수/메모리 쿼터 + TTL 타임아웃 GC. 없으면 half-open 프래그먼트로 OOM DoS.

내가 놓친 항목 (복습용)

  • [ ] (C)(D) 가변 payloadLen 으로 균일 오프셋 계산 → 재조립 깨짐 / ArgumentException
  • [ ] (B) fragCount/totalSize 무검증 → 거대 할당 OOM DoS + int 오버플로
  • [ ] (A) len < header 시 음수 payloadLen → 할당/Copy 예외 + 엔디안 의존
  • [ ] (B)(E) 재조립 상태 TTL/쿼터/세션격리 부재 → OOM, cross-session 오염
  • [ ] (C) 프래그먼트 간 fragCount/totalSize 일관성 미검증 → got IndexOutOfRange