← 문제로

13. 가변길이(VarInt) 정수 디코딩과 악성 길이 방어 — C#

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

해설 — 가변길이(VarInt) 정수 디코딩과 악성 길이 방어 — C#

난이도: 상

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

요약

신뢰 불가 입력 디코더가 경계 검사·종료 보장·오버플로 방어·길이 정합성 검사를 모두 빠뜨렸다. (A) VarInt 가 버퍼 끝을 넘어 읽으면 IndexOutOfRangeException(서버 스레드 다운), (B) 계속 비트(0x80)가 끝없이 켜진 입력은 shift 가 무한 증가 — C# 의 시프트는 카운트를 피연산자 비트수로 마스킹(shift & 63)하므로 UB 는 아니지만 값이 엉키며 무한루프로 진행, (C) 디코딩한 countList.Capacity = (int)count 를 설정해 과대 할당 / (int) 캐스팅으로 음수 → ArgumentOutOfRangeException, (D) nameLen 무검증으로 Encoding.UTF8.GetString 에 범위 초과 인자 → ArgumentOutOfRangeException 또는 대량 OOB 시도. 정답 한 줄: read 전에 남은 길이 검사, VarInt 최대 10바이트·64비트 한도, 개수/길이를 남은 버퍼로 상한 검증한 뒤에만 할당·복사.


문제점

(A) VarInt 범위 초과 read — 경계 검사 부재 (보안/안정성) ★간판

  • 증상: r.Buf[r.Pos++] 가 배열 끝을 넘으면 IndexOutOfRangeException. 잘못된 패킷 하나로 처리 스레드/세션이 죽는다(DoS).
  • 재현 조건: 마지막 바이트의 계속 비트가 켜진 채 버퍼가 끝나는 입력(... 0x80).
  • 근본 원인: 매 read 전 r.Pos < r.Buf.Length 검사 부재. C++ 라면 OOB read(UB)지만 C# 은 예외라 크래시. 어느 쪽이든 거부가 아니라 사고.

(B) 무한루프 + 시프트 마스킹 — 종료 방어 부재 (정확성/DoS)

  • 증상: 0x80 반복 입력에 shift 가 7,14,...,63,70(→ 70 & 63 = 6)... 으로 돌며 값이 오염되고, 계속 비트가 안 꺼지면 (A)로 가기 전까지 루프가 끝나지 않는다.
  • 재현 조건: continuation 만 반복.
  • 근본 원인: VarInt 최대 바이트 수(10)와 64비트 범위 검증 부재. C# 시프트는 UB 가 아니지만(<< 카운트 마스킹), 종료·범위 보장이 없으면 무한루프/값 오염.

(C) Capacity = (int)count — 과대 할당 / 캐스팅 함정 (보안/DoS)

  • 증상: 거대한 count(예: 2^40) → (int)count 가 음수이거나 거대한 양수. List.Capacity 에 음수 → ArgumentOutOfRangeException, 거대 양수 → 수 GB 즉시 할당 시도 → OOM.
  • 재현 조건: 본문 = [거대 count] 만.
  • 근본 원인: 외부 개수로 곧장 메모리 선점 + ulong→int 무검증 캐스팅(잘림/음수화).

(D) nameLen 무검증 — 예외 / OOB (보안) ★간판

  • 증상: Encoding.UTF8.GetString(Buf, Pos, (int)nameLen) 에 남은 버퍼보다 큰 nameLen(또는 (int) 음수) → ArgumentOutOfRangeException. 이어 r.Pos += (int)nameLen 으로 Pos 가 범위를 넘거나 int 오버플로로 음수.
  • 재현 조건: nameLen 을 남은 바이트보다 크게/거의 2^31 이상으로.
  • 근본 원인: declared ≤ remaining 검증 부재 + ulong→int 캐스팅 함정.

(보조) count 정합성 / 부분 결과

  • 각 아이템 최소 바이트(id 1B + nameLen 1B) 기준 count ≤ remaining / 2 로 즉시 컷 가능. 실패 시 outItems 에 부분 결과가 남아 호출부가 오해할 수 있다.

수정안

핵심: ① 모든 read 전 남은 길이 검사, ② VarInt 최대 10바이트 + 64비트 한도, ③ 개수/길이를 남은 버퍼와 대조한 뒤에만 할당·복사, ④ 모든 캐스팅에 범위 검증, 실패는 통째 거부.

public struct SafeReader
{
    public byte[] Buf; public int Pos;
    public int Left => Buf.Length - Pos;
    public bool Has(int n) => n >= 0 && Pos + n <= Buf.Length;  // 오버플로 안전

    public bool TryReadVarInt(out ulong value)
    {
        value = 0; int shift = 0;
        for (int i = 0; i < 10; i++)                 // 64비트 VarInt 최대 10바이트
        {
            if (!Has(1)) return false;               // (A) read 전 경계 검사
            byte b = Buf[Pos++];
            if (i == 9 && (b & 0x7E) != 0) return false;   // (B) 64비트 초과 거부
            value |= (ulong)(b & 0x7F) << shift;
            if ((b & 0x80) == 0) return true;
            shift += 7;
        }
        return false;                                // 10바이트 초과 continuation
    }
}

public static bool ParseItems(byte[] buf, out List<Item> outItems)
{
    outItems = new List<Item>();
    var r = new SafeReader { Buf = buf, Pos = 0 };

    if (!r.TryReadVarInt(out ulong count)) return false;
    const ulong kMinItemBytes = 2;                   // id(>=1) + nameLen(>=1)
    if (count > (ulong)r.Left / kMinItemBytes) return false;   // (C) 상한 검증
    outItems.Capacity = (int)count;                  // 이제 안전

    for (ulong i = 0; i < count; i++)
    {
        if (!r.TryReadVarInt(out ulong id64))   return false;
        if (id64 > uint.MaxValue)               return false;
        if (!r.TryReadVarInt(out ulong nameLen))return false;
        if (nameLen > (ulong)r.Left)            return false;   // (D) declared≤remaining

        var it = new Item { Id = (uint)id64,
                            Name = Encoding.UTF8.GetString(r.Buf, r.Pos, (int)nameLen) };
        r.Pos += (int)nameLen;
        outItems.Add(it);
    }
    return true;
}

포인트: read 전 Has, VarInt 10바이트·64비트 한도, 개수/길이를 Left 로 상한 검증한 뒤에만 Capacity/GetString, 모든 ulong→int 캐스팅 전 범위 검증.


더 나은 설계

1) 검증된 디코더(Protobuf 등) 또는 SequenceReader<byte>

  • 직접 짠 파서는 함정의 온상. .NET 의 System.Buffers SequenceReader/BinaryPrimitives, 또는 Protobuf 코덱은 경계·오버플로를 이미 다룬다. 트레이드오프: 의존성/포맷 고정.

2) 메시지 envelope 한도

  • 프레이밍에서 최대 메시지 크기·최대 아이템 수·최대 문자열 길이를 프로토콜 상수로 못 박고 초과는 프레이밍에서 컷. 디코더는 한도 내 버퍼만 받는다.

3) 캐스팅 규율

  • ulong→int 변환은 checked 또는 명시 범위 검증으로. 잘림/음수화가 보안 버그의 원천.

4) 퍼징/회귀 코퍼스

  • 디코더는 퍼징 대상에 포함하고, 과거 악성 패킷을 회귀 코퍼스로 보관.

면접 포인트

  • 핵심: 신뢰 경계 입력 검증 — read 전 길이 확인, declared ≤ remaining, 외부 개수로 즉시 할당 금지, 가변 인코딩 종료·범위 한도. C# 은 OOB 가 예외(크래시)지만 그래도 "거부"가 정답.
  • 예상 질문:
    1. "C# 은 OOB 가 UB 가 아닌데 왜 위험?" → 예외로 스레드/세션 다운(DoS). 거부로 처리해야.
    2. "(int)count 캐스팅의 함정은?" → ulong→int 잘림/음수화로 음수 Capacity 예외 또는 의도와 다른 크기. 범위 검증 필수.
    3. "count/length 를 어떻게 신뢰?" → 신뢰 안 함. 남은 버퍼 기반 상한으로 항상 대조.

본 문제는 카탈로그 11.프로토콜에 새로 추가된 상황이며, 기존 protocol_version/problem1 (고정 길이프리픽스)·problem4(패킷 검증)·problem8(프래그먼트 재조립)과 결함 축이 다르다 (가변길이 인코딩 자체의 종료·오버플로·할당 방어).