← 문제로

29. 비트 단위 스트림 리더의 경계·폭 검증 부재 (C#)

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

해설 — 비트 단위 스트림 리더의 경계·폭 검증 부재 (C#)

난이도: 중상

요약

비트 패킹 스트림을 읽으면서 (1) 매 비트 접근 전 남은 비트 수를 검증하지 않아 버퍼 끝을 넘어 읽고(관리 언어라 IndexOutOfRangeException → 파서 크래시/DoS), (2) 와이어에서 온 필드 폭 w 와 개수 count 를 남은 비트와 대조하지 않아 작은 입력으로 대량 반복·과대 읽기를 유발하며, (3) ReadBits(0)·경계에서 커서가 조용히 어긋난다.

문제점

  • [비트 OOB / 남은 비트 미검증] (A) int bit = (_buf[byteIdx] >> bitIdx) & 1;
    • 증상: _bitPos_buf.Length*8 을 넘어가면 byteIdx 가 배열 밖 → IndexOutOfRangeException. 신뢰 불가 입력이 파서 스레드를 예외로 죽인다(요청당 크래시 = 원격 DoS).
    • 재현: 헤더에 count 만 크게 쓰고 실제 바이트는 몇 개만 보내면, 두어 필드 읽고 곧 버퍼 밖 접근.
    • 근본원인: ReadBits 진입 시 _bitPos + n <= _buf.Length*8 를 검사하지 않음. 실패는 예외가 아니라 "불완전 프레임" 신호로 다뤄야 함.
  • [신뢰 불가 폭/개수로 인한 증폭] (B) uint val = r.ReadBits(w); (그리고 count 루프)
    • 증상: count 가 255, 각 w 가 31이면 필요한 비트는 최대 8 + 255*(5+31) = 약 9188비트인데 실제 패킷이 그만큼 없으면 (A) 로 크래시. 반대로 w 가 큰 값이면 한 필드가 버퍼를 통째로 삼킨다. List<Field> 도 count 만큼 무검증 증가.
    • 근본원인: 개수·폭·총 비트 예산에 상한과 남은-바이트 대조가 없음. 폭 5비트라 0..31 로 제한되긴 하나(우연), 남은 비트 검증이 없으면 여전히 OOB.
    • 추가: ReadBits(0) 은 0을 반환하고 커서 무전진 — 만약 폭 필드가 0을 허용하면 무의미 필드가 count 만큼 쌓일 수 있다(설계에 따라 무한 진행 위험).
  • [uint→int 캐스팅] int w = (int)r.ReadBits(5) — 여기선 5비트라 안전하지만, 폭 필드가 32비트였다면 음수 n 이 되어 루프가 돌지 않거나 v << 1 이 정의되지 않는 값으로 흐른다. 폭은 항상 명시 범위로 검증해야 한다.

수정안

ReadBits 가 남은 비트를 검사해 부족하면 실패(예외 대신 bool/Try 패턴). 파서는 count·w 를 상한·남은 비트와 대조.

public sealed class BitReader
{
    private readonly byte[] _buf;
    private int _bitPos;
    private readonly int _bitLen;
    public BitReader(byte[] buf) { _buf = buf; _bitLen = buf.Length * 8; }

    public int Remaining => _bitLen - _bitPos;

    public bool TryReadBits(int n, out uint v)
    {
        v = 0;
        if ((uint)n > 32) return false;           // 폭 범위 검증
        if (n > Remaining) return false;          // (A) 남은 비트 검증 → 예외 대신 실패
        for (int i = 0; i < n; i++)
        {
            int byteIdx = _bitPos >> 3, bitIdx = 7 - (_bitPos & 7);
            v = (v << 1) | (uint)((_buf[byteIdx] >> bitIdx) & 1);
            _bitPos++;
        }
        return true;
    }
}

public static bool TryParse(byte[] packet, out List<Field> fields)
{
    fields = new List<Field>();
    var r = new BitReader(packet);
    if (!r.TryReadBits(8, out uint count)) return false;
    const int MaxFields = 128;
    if (count > MaxFields) return false;                       // (B) 개수 상한
    for (uint i = 0; i < count; i++)
    {
        if (!r.TryReadBits(5, out uint wRaw)) return false;
        int w = (int)wRaw;
        if (w == 0 || w > 24) return false;                   // 폭 범위(도메인) 검증
        if (!r.TryReadBits(w, out uint val)) return false;    // 남은 비트 자동 대조
        fields.Add(new Field { Width = w, Value = val });
    }
    return true;
}

더 나은 설계

  • 실패는 값으로: 파서 전체가 Try... → 실패 시 프레임 폐기/세션 종료. 신뢰 불가 입력에 예외를 던지면 DoS 표면이 된다(protocol_version/problem24,25 와 동일 철학).
  • 총 비트 예산: count*(5+maxW) 상한을 먼저 검산해 명백히 불가능한 헤더를 조기 거절.
  • 바이트 정렬 힌트: 필드 사이 정렬(padding)을 규약에 두면 리더 구현이 단순·빠르고, 부분 프레임 경계도 명확.
  • 읽기 후 잔여 비트 규약: 프레임 끝에 남은 비트는 0(패딩)이어야 한다고 못박아 디싱크 조기 탐지.

면접 포인트

  • 비트 단위 커서에서 "바이트 인덱스 = bitPos>>3, 비트 = 7-(bitPos&7)" 산술과, 접근 전 남은 비트 검증이 없으면 왜 OOB 인가.
  • 신뢰 불가 입력에서 예외 기반 파서가 DoS 가 되는 이유와 Try 패턴으로의 전환.
  • 개수/폭/총예산 상한 — "선언된 크기를 믿지 말라"는 역직렬화 원칙.