← 문제로

48. 프래그먼트 재조립의 offset/length 경계·겹침·구멍 미검증 · C#

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

해설 — 프래그먼트 재조립의 offset/length 경계·겹침·구멍 미검증 · C#

난이도: 중상

요약

재조립기는 신뢰할 수 없는 프래그먼트가 준 totalSize 로 버퍼를 잡고(A), offset 위치에 무조건 복사한다(B). offset/payload.Length 가 버퍼 경계 안인지, 조각들이 겹치거나 구멍이 없는지, totalSize 가 합리적인지 전혀 검증하지 않는다. 조작된 프래그먼트로 과대 할당(메모리 고갈), 예외 크래시, 겹침 덮어쓰기, "받은 바이트 수"만으로 완성 오판이 가능하다. (같은 카테고리 problem8 이 fragIndex/fragCount 인덱스 기반 재조립이라면, 본 문제는 바이트 offset/length 주소 모델의 경계·겹침·구멍 검증이 핵심이다.)

문제점

입력 경계 검증 부재 / 재조립 무결성(겹침·구멍) 미보장 / 자원 상한 부재

  • 증상과 재현:
    1. 과대 할당(DoS): 첫 프래그먼트가 totalSize = 2_000_000_000 을 주면 (A)에서 2GB 배열을 즉시 잡는다. msgId 만 바꿔 반복하면 메모리가 고갈된다.
    2. 경계 밖 복사 → 크래시: offset = totalSize - 1, Payload.Length = 500 이면 (B)의 Array.Copy 가 대상 범위를 벗어나 ArgumentException 을 던진다. C#은 배열 경계를 검사하므로 OOB 메모리 훼손은 아니지만, 검증되지 않은 원격 입력으로 수신 스레드가 예외로 죽는다(원격 DoS).
    3. 겹침 덮어쓰기 / 위조: 두 프래그먼트가 같은 offset 범위를 다른 내용으로 채우면 나중 것이 앞 것을 덮는다. Received 는 payload 길이를 단순 누적하므로, 같은 범위를 두 번 보내면 실제로는 절반만 찼는데 Received >= totalSize 가 되어 구멍이 남은 채 완성으로 오판된다. 그 구멍은 0바이트(또는 이전 msgId 잔재)로 디스패치된다.
    4. 완성 판정 오류: Received 는 "서로 다른 바이트가 몇 개 찼는가"가 아니라 "복사 호출의 payload 길이 합"이다. 겹침·중복 전송이 있으면 실제 커버리지와 어긋난다.
  • 근본 원인: totalSize, offset, payload.Length 는 전부 원격이 통제하는 값인데 상한·정합성 검사 없이 메모리 연산과 완성 판정에 그대로 쓰였다.

수정안

모든 필드를 경계 검증하고, 채워진 구간을 비트맵/구간집합으로 추적해 겹침·구멍을 정확히 판정한다.

const int MaxMessageSize = 1 << 20;   // 1MB 상한(프로토콜 정책)
const int MaxConcurrentReassemblies = 1024;

public byte[] OnFragment(in Fragment f)
{
    // 1) 필드 검증(오버플로 안전)
    if (f.TotalSize <= 0 || f.TotalSize > MaxMessageSize) return DropTooLarge();
    long end = (long)f.Offset + f.Payload.Length;
    if (f.Offset < 0 || f.Payload.Length <= 0 || end > f.TotalSize) return DropBad();

    if (!_pending.TryGetValue(f.MsgId, out var p))
    {
        if (_pending.Count >= MaxConcurrentReassemblies) return DropBusy();
        p = new Pending(f.TotalSize);
        _pending[f.MsgId] = p;
    }
    // 2) 같은 메시지의 조각끼리 totalSize 일관성 검사
    if (p.Buffer.Length != f.TotalSize) return DropInconsistent();

    // 3) 실제로 '새로 채워진' 바이트만 커버리지에 반영(겹침은 진행에 기여 X)
    int newlyFilled = p.Coverage.Mark(f.Offset, f.Payload.Length); // 구간집합/비트맵
    Array.Copy(f.Payload, 0, p.Buffer, f.Offset, f.Payload.Length);
    p.Filled += newlyFilled;

    if (p.Filled == p.Buffer.Length && p.Coverage.IsComplete())  // 구멍 없음 확인
    {
        _pending.Remove(f.MsgId);
        return p.Buffer;
    }
    return null;
}

추가로 재조립 타임아웃(오래된 미완성 msgId 를 주기적으로 폐기)과 msgId 당 조각 수 상한을 둔다. 그러지 않으면 "영원히 미완성"인 상태가 메모리에 쌓인다.

더 나은 설계

  • 커버리지 추적: 단순 바이트 카운트 대신 채워진 구간을 비트맵(또는 정렬된 구간 리스트)으로 관리하면 겹침·구멍·중복을 정확히 판정할 수 있다. 완성은 "전 구간이 정확히 한 번 이상 덮였는가"로 판정한다.
  • 자원 상한: 메시지 크기, 동시 재조립 수, 미완성 수명(타임아웃)에 모두 상한을 둔다. 재조립은 상태를 보유하므로 원격이 상태 폭증을 유도할 수 있다.
  • 트레이드오프: 구간 추적은 약간의 CPU·메모리를 더 쓰지만, UDP 특성상 중복·재정렬·조작이 정상 범주이므로 필수 비용이다.

면접 포인트

  • 조립형 프로토콜(프래그먼트, 배치, TLV)의 3대 검증: 경계(offset+len ≤ total), 정합성(조각 간 total 일치·겹침 금지), 자원 상한(크기·개수·수명). 하나라도 빠지면 DoS 또는 데이터 오염이 된다.
  • "받은 바이트 수 == 전체 크기"는 겹침이 있으면 완성을 보장하지 못한다. 진행도는 반드시 "고유하게 덮인 커버리지"로 측정해야 한다.