← 문제로

17. 가변길이 문자열/바이트 필드 역직렬화와 길이 검증 (C#)

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

해설 — 가변길이 문자열/바이트 필드 역직렬화와 길이 검증 (C#)

난이도: 상

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

요약

Reader공격자 제어 길이/개수를 검증 없이 신뢰한다. C# 은 메모리 안전 언어라 경계를 넘으면 IndexOutOfRangeException/ArgumentOutOfRangeException 이 나서 C++ 처럼 인접 메모리가 새지는 않지만, (A) 그 예외가 검증되지 않은 채 파싱 핫패스에서 던져지면 DoS(작은 악성 패킷 1개로 예외·연결 처리 비용)이고, 더 심각한 (B) new byte[len](u32 → 최대 ~2GB 단일 배열 한도/OutOfMemoryExceptionnew List<string>(count)신뢰되지 않은 선할당은 작은 패킷으로 OOM/LOH 압박 DoS 를 만든다. (C) 길이 상한·UTF-8 검증 부재로 잘못된 닉네임/오버롱 문자열이 통과한다. 정답 한 줄: 읽기 전 pos + need <= length 검사 + 길이/개수 프로토콜 상한 + 신뢰되지 않은 값 선할당 금지(clamp) + UTF-8 검증.

변별: "정상 프레이밍된 메시지 내부의 가변길이 필드" 파싱. problem1(스트림 프레이밍), problem4(범용 패킷 검증), problem13(VarInt 정수)과 달리 문자열/블롭 본문 길이·개수· 인코딩이 핵심. 언어 대비: C++ 은 silent over-read/정보유출, C# 은 예외/할당-DoS.


문제점

(B) 신뢰되지 않은 길이/개수 선할당 — 메모리 DoS (보안/리소스) ★간판

  • 증상: new byte[len](ReadBlob, u32) 은 공격자 len 으로 거대한 배열을 즉시 할당 → OutOfMemoryException/GC LOH 압박/지연. new List<string>(count) 도 큰 count 로 즉시 큰 내부 배열. 실제 페이로드가 없어도 선언된 크기만으로 할당 → 작은 패킷 다수로 서버 메모리 고갈. (.NET 단일 배열은 ~2GB 제한이라 4GB 까지는 아니지만 충분히 치명적.)
  • 재현조건: len/count 를 페이로드보다 크게 보낸 악성 패킷.
  • 근본 원인: "선언 크기" 와 "가용 바이트" 분리·상한이 없다.

(A) 경계 검사 없는 읽기 — 예외 기반 DoS / 부분 파싱 (보안/버그)

  • 증상: ReadU16/Encoding.UTF8.GetString(_data, _pos, len)/Array.Copy 가 남은 바이트를 확인하지 않아, len 이 크면 ArgumentOutOfRangeException 등이 파싱 도중 발생. 메모리 유출은 없지만(안전 언어), 검증 안 된 예외가 핫패스에서 반복되면 처리비용· 로그폭주로 DoS 이고, 예외 처리 위치에 따라 부분 파싱 상태가 남는다.
  • 근본 원인: 읽기 단위마다 _pos + need <= _data.Length 불변식 미검사.

(C) 길이 상한·UTF-8 검증 부재 — 도메인 규칙 붕괴 (정확성)

  • 증상: 닉네임 64자, 제목 64자, 메일 100개 같은 도메인 상한이 없다. 또 Encoding.UTF8.GetString 은 잘못된 바이트를 예외 없이 U+FFFD 로 치환해 조용히 통과시킨다(검증 아님) → 길이/내용 규칙을 우회한 닉네임·채팅이 저장/표시.
  • 근본 원인: 프로토콜 상한과 인코딩 검증을 파서가 강제하지 않음.

수정안

using System;
using System.Collections.Generic;
using System.Text;

public class ParseException : Exception { public ParseException(string m) : base(m) {} }

public class Reader
{
    private readonly byte[] _data;
    private int _pos;
    public Reader(byte[] data) { _data = data; _pos = 0; }

    private int Remaining => _data.Length - _pos;   // _pos <= Length 불변식 유지
    private void Need(int n)
    {
        if (n < 0 || n > Remaining) throw new ParseException("short read");
    }

    public ushort ReadU16()
    {
        Need(2);
        ushort v = (ushort)(_data[_pos] | (_data[_pos + 1] << 8));
        _pos += 2; return v;
    }
    public uint ReadU32()
    {
        Need(4);
        uint v = (uint)(_data[_pos] | (_data[_pos+1] << 8) | (_data[_pos+2] << 16) | (_data[_pos+3] << 24));
        _pos += 4; return v;
    }

    public string ReadString(int maxLen)
    {
        ushort len = ReadU16();
        if (len > maxLen) throw new ParseException("string too long"); // 프로토콜 상한
        Need(len);                                                     // 가용 바이트 확인
        // 엄격 디코딩: 잘못된 UTF-8 이면 예외(치환 금지)
        var dec = new UTF8Encoding(false, throwOnInvalidBytes: true);
        string s = dec.GetString(_data, _pos, len);
        _pos += len;
        return s;
    }

    public byte[] ReadBlob(int maxLen)
    {
        uint len = ReadU32();
        if (len > (uint)maxLen) throw new ParseException("blob too long");
        Need((int)len);                       // 실제 바이트가 있어야 할당
        var buf = new byte[len];
        Array.Copy(_data, _pos, buf, 0, (int)len);
        _pos += (int)len;
        return buf;
    }
}

public static class MailParser
{
    const int MaxTitle = 64, MaxMailCount = 100;
    public static List<string> ParseMailTitles(byte[] buf)
    {
        var r = new Reader(buf);
        ushort count = r.ReadU16();
        if (count > MaxMailCount) throw new ParseException("too many titles");
        var titles = new List<string>(Math.Min((int)count, MaxMailCount)); // clamp
        for (int i = 0; i < count; i++)
            titles.Add(r.ReadString(MaxTitle));
        return titles;
    }
}

포인트

  • Need(n) 으로 모든 읽기 전 경계 검사 → 예외 기반 DoS·부분 파싱 차단(빠른 거부).
  • 길이/개수 프로토콜 상한 + 위반 시 패킷 폐기/연결 차단.
  • 선할당은 검증·clamp 후 → 선언 크기만으로 OOM 못 만든다.
  • UTF8Encoding(throwOnInvalidBytes: true)엄격 검증(치환 금지).

더 나은 설계 (+트레이드오프)

  1. 스키마 기반 직렬화(Protobuf/FlatBuffers/MessagePack) + 검증 계층. 트레이드오프: 포맷 고정·의존성, 도메인 상한은 여전히 직접 강제.
  2. ReadOnlySpan<byte> 기반 zero-allocation 파서: 경계 검사 후 슬라이스만, 꼭 필요할 때 문자열화 → 할당 최소화. 트레이드오프: span 수명/escape 주의.
  3. 전체 메시지·필드 상한을 한 곳에 선언하고 파서가 강제. 트레이드오프: 명세-코드 동기화.
  4. 퍼징 + 비정상 입력 테스트 스위트 상시화로 파서 강건성 회귀 방지.

면접 포인트 (예상 질문)

  1. 메모리 안전한 C# 인데도 이 파서가 왜 위험한가? C++ 의 over-read 와 무엇이 같고 다른가? (할당-DoS 는 공통, 정보유출은 C++ 고유)
  2. Encoding.UTF8.GetString 이 잘못된 바이트를 "검증" 하지 않는다는 게 무슨 뜻인가? 어떻게 엄격 검증하나?
  3. u32 길이로 거대한 배열 할당을 유발하는 DoS 를, 정상 기능을 깨지 않고 막는 설계는?