17. 가변길이 문자열/바이트 필드 역직렬화와 길이 검증 (C#)
난이도 상해설 — 가변길이 문자열/바이트 필드 역직렬화와 길이 검증 (C#)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
Reader 가 공격자 제어 길이/개수를 검증 없이 신뢰한다. C# 은 메모리 안전 언어라
경계를 넘으면 IndexOutOfRangeException/ArgumentOutOfRangeException 이 나서 C++
처럼 인접 메모리가 새지는 않지만, (A) 그 예외가 검증되지 않은 채 파싱 핫패스에서 던져지면
DoS(작은 악성 패킷 1개로 예외·연결 처리 비용)이고, 더 심각한 (B) new byte[len](u32 →
최대 ~2GB 단일 배열 한도/OutOfMemoryException)·new 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)로 엄격 검증(치환 금지).
더 나은 설계 (+트레이드오프)
- 스키마 기반 직렬화(Protobuf/FlatBuffers/MessagePack) + 검증 계층. 트레이드오프: 포맷 고정·의존성, 도메인 상한은 여전히 직접 강제.
ReadOnlySpan<byte>기반 zero-allocation 파서: 경계 검사 후 슬라이스만, 꼭 필요할 때 문자열화 → 할당 최소화. 트레이드오프: span 수명/escape 주의.- 전체 메시지·필드 상한을 한 곳에 선언하고 파서가 강제. 트레이드오프: 명세-코드 동기화.
- 퍼징 + 비정상 입력 테스트 스위트 상시화로 파서 강건성 회귀 방지.
면접 포인트 (예상 질문)
- 메모리 안전한 C# 인데도 이 파서가 왜 위험한가? C++ 의 over-read 와 무엇이 같고 다른가? (할당-DoS 는 공통, 정보유출은 C++ 고유)
Encoding.UTF8.GetString이 잘못된 바이트를 "검증" 하지 않는다는 게 무슨 뜻인가? 어떻게 엄격 검증하나?- u32 길이로 거대한 배열 할당을 유발하는 DoS 를, 정상 기능을 깨지 않고 막는 설계는?
해설 — 가변길이 문자열/바이트 필드 역직렬화와 길이 검증 (C++)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
Reader 가 공격자 제어 길이/개수를 검증 없이 신뢰한다. (A) readU16/readU32 와
문자열 본문 복사에 남은 바이트 경계 검사가 전혀 없어 버퍼 끝을 넘어 읽는다
(data_[pos_++] OOB read → 인접 힙/다른 플레이어 패킷 정보 유출, 또는 크래시). (B)
reserve(len)·vector buf(len)·reserve(count) 가 신뢰되지 않은 값으로 거대한 메모리를
즉시 선할당(u32 블롭은 최대 ~4GB, count×len 중첩 증폭) → 6바이트 패킷으로 OOM/DoS.
(C) pos_ 가 size_ 와 무관하게 증가해 이후 remaining = size_ - pos_ 같은 size_t 산술이
언더플로 되면 거대한 양수가 되어 무한루프/추가 OOB 로 번진다. 정답 한 줄: 모든 읽기
전에 need <= remaining 을 검사하고, 길이/개수에 프로토콜 상한을 두며, 신뢰되지 않은
값으로 선할당하지 말고(또는 clamp), UTF-8 을 검증한다.
변별: 본 문제는 "정상 프레이밍된 메시지 내부의 가변길이 필드" 파싱이다. problem1(길이프리픽스 스트림 프레이밍/부분수신), problem4(범용 패킷 검증), problem13(VarInt 정수 디코딩)과 달리, 여기서는 문자열/블롭 본문의 길이·개수·UTF-8 와 size_t 커서 산술이 핵심이다.
문제점
(A) 경계 검사 없는 읽기 — 버퍼 오버리드 / 정보 유출 (보안/버그) ★간판
- 증상:
readU16은data_[pos_+1]까지,readString은data_[pos_++]를len번, 남은 바이트 확인 없이 읽는다.len이 실제 남은 바이트보다 크면 버퍼 경계를 넘어 인접 메모리를 읽어 문자열에 담는다 → 인접 힙(다른 세션 버퍼/키/포인터)이 클라로 유출되거나, 매핑되지 않은 페이지 접근으로 크래시. 전형적 Heartbleed류 over-read. - 재현조건:
len(또는 count) 을 실제 페이로드보다 크게 보낸 악성 패킷 한 개면 충분. - 근본 원인: 읽기 단위마다
pos_ + need <= size_불변식을 검사하지 않음.
(B) 신뢰되지 않은 길이/개수 선할당 — 메모리 증폭 DoS (보안/리소스)
- 증상:
s.reserve(len),std::vector<uint8_t> buf(len)(u32 → 최대 4,294,967,295),titles.reserve(count)가 검증 전에 할당한다.readBlob에len=0xFFFFFFFF만 실어 보내면 실제 바이트가 없어도 ~4GB 할당 시도 → OOM/스왑/프로세스 종료.count가 크면 벡터 예약 + 루프마다 문자열 예약으로 곱셈 증폭. 작은 패킷으로 거대한 자원 소모. - 근본 원인: "선언된 크기" 와 "실제 가용 바이트" 를 분리하지 않고, 상한(cap)이 없다.
(C) size_t 커서 언더플로 / UTF-8 미검증 — 파싱 붕괴 (버그/정확성)
- 증상:
pos_가size_를 넘어 증가해도 막지 않는다. 다른 코드가size_ - pos_(size_t) 로 잔여를 계산하면 언더플로 → 거대한 양수 → 추가 OOB/무한루프. 또 바이트를 그대로std::string에 담아 불완전/오버롱 UTF-8 을 통과시켜 다운스트림 (DB, 로그, 길이기반 로직, 클라 렌더)에서 깨진다. 닉네임 길이 제한 등 도메인 규칙도 없음. - 근본 원인: 커서 전진에 상한 검사가 없고, 인코딩 유효성/도메인 상한 검증이 없다.
수정안
핵심: ① 읽기 단위마다 경계 검사(실패 시 거부), ② 길이·개수에 프로토콜 상한, ③ 선할당은 가용 바이트로 clamp, ④ UTF-8/도메인 검증.
#include <cstdint>
#include <string>
#include <vector>
#include <stdexcept>
#include <algorithm>
struct ParseError : std::runtime_error { using std::runtime_error::runtime_error; };
class Reader {
public:
Reader(const uint8_t* data, size_t size) : data_(data), size_(size) {}
size_t remaining() const { return size_ - pos_; } // pos_ <= size_ 불변식 유지
void need(size_t n) const {
if (n > remaining()) throw ParseError("short read"); // 항상 경계 검사
}
uint16_t readU16() {
need(2);
uint16_t v = static_cast<uint16_t>(data_[pos_] | (data_[pos_ + 1] << 8));
pos_ += 2;
return v;
}
uint32_t readU32() {
need(4);
uint32_t v = static_cast<uint32_t>(data_[pos_]) |
(static_cast<uint32_t>(data_[pos_+1]) << 8) |
(static_cast<uint32_t>(data_[pos_+2]) << 16) |
(static_cast<uint32_t>(data_[pos_+3]) << 24);
pos_ += 4;
return v;
}
std::string readString(size_t maxLen) {
uint16_t len = readU16();
if (len > maxLen) throw ParseError("string too long"); // 프로토콜 상한
need(len); // 가용 바이트 확인 후
std::string s(reinterpret_cast<const char*>(data_ + pos_), len); // 그제서야 복사
pos_ += len;
if (!isValidUtf8(s)) throw ParseError("invalid utf8");
return s;
}
std::vector<uint8_t> readBlob(size_t maxLen) {
uint32_t len = readU32();
if (len > maxLen) throw ParseError("blob too long");
need(len); // 실제 바이트가 있어야 할당
std::vector<uint8_t> buf(data_ + pos_, data_ + pos_ + len);
pos_ += len;
return buf;
}
private:
static bool isValidUtf8(const std::string&); // 표준 UTF-8 검증
const uint8_t* data_;
size_t size_;
size_t pos_ = 0;
};
constexpr size_t kMaxTitle = 64;
constexpr size_t kMaxMailCount = 100;
std::vector<std::string> parseMailTitles(const uint8_t* buf, size_t n) {
Reader r(buf, n);
uint16_t count = r.readU16();
if (count > kMaxMailCount) throw ParseError("too many titles"); // 개수 상한
std::vector<std::string> titles;
titles.reserve(std::min<size_t>(count, kMaxMailCount)); // clamp 예약
for (uint16_t i = 0; i < count; ++i)
titles.push_back(r.readString(kMaxTitle));
return titles;
}
포인트
need(n)으로 모든 읽기 전 경계 검사 → 오버리드/유출 원천 차단.remaining()은pos_ <= size_불변식 덕에 언더플로 불가.- 길이/개수에 프로토콜 상한(닉네임·제목·메일 수)을 두고 위반 시 연결 차단/패킷 폐기.
- 선할당은 검증·clamp 후 → 작은 패킷으로 거대한 메모리 못 만든다.
- UTF-8 검증으로 다운스트림 깨짐 방지.
더 나은 설계 (+트레이드오프)
- 스키마 기반 직렬화(FlatBuffers/Protobuf/Cap'n Proto) + 검증 계층: 손으로 짠 파서 대신 검증된 코드 생성. 트레이드오프: 의존성/포맷 고정, 여전히 "도메인 상한" 검증은 직접 해야.
- zero-copy view(
std::string_view/span) + 지연 복사: 경계 검사 후 뷰만 넘기고 꼭 필요할 때 복사 → 할당 최소화. 트레이드오프: 수명 관리 주의(버퍼보다 오래 살면 안 됨). - 전체 메시지 크기 상한 + 필드별 상한을 한 곳에 선언(프로토콜 명세에 max 명시). 파서가 그 표를 강제. 트레이드오프: 명세-코드 동기화 필요.
- 퍼징(libFuzzer/AFL)으로 파서 강건성 검증 + over-read 검출용 ASAN 상시화.
면접 포인트 (예상 질문)
len검증 없이data_[pos_++]를len번 읽으면 C++ 에서 무슨 일이 생기나? 왜 정보 유출(Heartbleed류)로 이어지는가?size_ - pos_를 size_t 로 계산하면 왜 위험하고,pos_ <= size_불변식이 어떻게 막는가?- u32 길이 1개로 4GB 할당을 유발하는 DoS 를, 기능을 깨지 않으면서 어떻게 막는가? (상한 + 가용바이트 검증 + clamp)