13. 가변길이(VarInt) 정수 디코딩과 악성 길이 방어 — C#
난이도 상해설 — 가변길이(VarInt) 정수 디코딩과 악성 길이 방어 — C#
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
신뢰 불가 입력 디코더가 경계 검사·종료 보장·오버플로 방어·길이 정합성 검사를 모두
빠뜨렸다. (A) VarInt 가 버퍼 끝을 넘어 읽으면 IndexOutOfRangeException(서버 스레드 다운),
(B) 계속 비트(0x80)가 끝없이 켜진 입력은 shift 가 무한 증가 — C# 의 시프트는 카운트를
피연산자 비트수로 마스킹(shift & 63)하므로 UB 는 아니지만 값이 엉키며 무한루프로
진행, (C) 디코딩한 count 로 List.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.BuffersSequenceReader/BinaryPrimitives, 또는 Protobuf 코덱은 경계·오버플로를 이미 다룬다. 트레이드오프: 의존성/포맷 고정.
2) 메시지 envelope 한도
- 프레이밍에서 최대 메시지 크기·최대 아이템 수·최대 문자열 길이를 프로토콜 상수로 못 박고 초과는 프레이밍에서 컷. 디코더는 한도 내 버퍼만 받는다.
3) 캐스팅 규율
ulong→int변환은checked또는 명시 범위 검증으로. 잘림/음수화가 보안 버그의 원천.
4) 퍼징/회귀 코퍼스
- 디코더는 퍼징 대상에 포함하고, 과거 악성 패킷을 회귀 코퍼스로 보관.
면접 포인트
- 핵심: 신뢰 경계 입력 검증 — read 전 길이 확인, declared ≤ remaining, 외부 개수로 즉시 할당 금지, 가변 인코딩 종료·범위 한도. C# 은 OOB 가 예외(크래시)지만 그래도 "거부"가 정답.
- 예상 질문:
- "C# 은 OOB 가 UB 가 아닌데 왜 위험?" → 예외로 스레드/세션 다운(DoS). 거부로 처리해야.
- "
(int)count캐스팅의 함정은?" → ulong→int 잘림/음수화로 음수 Capacity 예외 또는 의도와 다른 크기. 범위 검증 필수. - "count/length 를 어떻게 신뢰?" → 신뢰 안 함. 남은 버퍼 기반 상한으로 항상 대조.
본 문제는 카탈로그 11.프로토콜에 새로 추가된 상황이며, 기존 protocol_version/problem1 (고정 길이프리픽스)·problem4(패킷 검증)·problem8(프래그먼트 재조립)과 결함 축이 다르다 (가변길이 인코딩 자체의 종료·오버플로·할당 방어).
해설 — 가변길이(VarInt) 정수 디코딩과 악성 길이 방어 — C++
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
신뢰 불가 입력을 다루는 디코더가 경계 검사, 종료 보장, 오버플로 방어, 길이 정합성 검사를
전부 빠뜨렸다. 구체적으로 (A) VarInt 가 버퍼 끝을 넘어 읽어도 막지 않아 OOB read(크래시/
정보 누출), (B) 계속 비트(0x80)가 끝없이 켜진 입력은 shift 가 64를 넘어 시프트 UB +
무한 진행, (C) 디코딩한 count 를 검증 없이 reserve(count) 해 과대 할당(메모리 고갈
DoS), (D) nameLen 을 남은 버퍼와 대조하지 않고 복사·전진해 대량 OOB read / pos
오버플로. 정답 한 줄: 모든 read 는 남은 길이를 먼저 검사하고, VarInt 는 최대 바이트 수
(10)와 64비트 한도를 강제하며, 디코딩한 개수/길이는 "남은 버퍼로 수용 가능한가"로 반드시
검증한다(신뢰 경계의 입력 검증).
문제점
(A) VarInt OOB read — 경계 검사 부재 (보안/안정성) ★간판
- 증상:
r.buf[r.pos++]가 버퍼 끝을 넘어 읽는다. 크래시 또는 인접 메모리 누출. - 재현 조건: 마지막 바이트의 계속 비트(0x80)가 켜진 채 버퍼가 끝나는 입력
(예:
... 0x80으로 끝). 루프가 다음 바이트를 읽으러len을 넘어간다. - 근본 원인: 매 바이트 read 전에
r.pos < r.len을 확인하지 않는다. 신뢰 불가 입력에서 "읽기 전에 남았는지 검사"가 없다.
(B) 무한 진행 + 시프트 UB — 종료/오버플로 방어 부재 (보안/UB)
- 증상: 계속 비트가 계속 켜진 입력은
shift가 7,14,...,70,... 로 무한 증가.(uint64_t)x << shift에서 shift >= 64 는 정의되지 않은 동작(UB). (A)와 결합해 버퍼를 넘어 폭주. - 재현 조건:
0x80 0x80 0x80 ...(continuation 만 반복). - 근본 원인: VarInt 최대 바이트 수(64비트면 10바이트)를 강제하지 않고,
shift한도와 값 범위(상위 비트가 64비트 초과인지) 검증이 없다.
(C) reserve(count) 과대 할당 — DoS (보안/자원)
- 증상: 공격자가
count를 거대한 값(예: 2^60)으로 보내면out.reserve(count)가 수 EB 메모리를 요구 →bad_alloc/OOM 으로 서버 다운. 실제 데이터는 한 바이트도 없어도 된다. - 재현 조건: 본문 =
[거대 count VarInt]만. - 근본 원인: 외부가 준 개수로 곧장 메모리를 잡는다. count 는 남은 버퍼가 담을 수 있는 최소 아이템 크기로 나눈 상한을 절대 넘을 수 없는데 그 검사가 없다.
(D) nameLen 무검증 복사/전진 — OOB read + 길이 정합성 (보안) ★간판
- 증상:
it.name.assign(buf+pos, nameLen)가 남은 버퍼보다 큰nameLen으로 대량 OOB read(누출/크래시). 이어r.pos += nameLen으로pos가len을 한참 넘어가 다음 반복의 read 가 더 멀리 폭주(또는pos산술 오버플로). - 재현 조건:
nameLen을 남은 바이트보다 크게(또는 거의 2^64) 설정. - 근본 원인: "선언된 길이"를 "실제 남은 길이"와 대조하지 않았다. 가변 길이 필드의 황금률(declared ≤ remaining) 위반.
(보조) count 정합성 / 부분 파싱
- 각 아이템은 최소 몇 바이트(최소 id 1B + nameLen 1B)를 차지하므로,
count가remaining / min_item_bytes를 넘으면 즉시 거부 가능. 또한 파싱 실패 시out에 부분 결과가 남지 않게 해야 한다(호출부 오해 방지).
수정안
핵심: ① 모든 read 전에 남은 길이 검사(Ensure), ② VarInt 는 최대 10바이트 + 64비트 한도
강제, ③ 길이/개수는 남은 버퍼와 대조, ④ reserve 는 검증된 상한 이하로, 실패는 전부 거부.
class SafeReader {
public:
SafeReader(const uint8_t* b, size_t n) : buf_(b), len_(n) {}
bool Remaining(size_t n) const { return pos_ + n <= len_; } // 오버플로 안전
size_t Left() const { return len_ - pos_; }
bool ReadVarInt(uint64_t& out) {
uint64_t result = 0; int shift = 0;
for (int i = 0; i < 10; ++i) { // 64비트 VarInt 는 최대 10바이트
if (!Remaining(1)) return false; // (A) read 전 경계 검사
uint8_t b = buf_[pos_++];
// 마지막(10번째) 바이트는 상위 비트만 유효 → 64비트 초과 방지
if (i == 9 && (b & 0x7E)) return false; // (B) 범위 초과 거부
result |= (uint64_t)(b & 0x7F) << shift;
if ((b & 0x80) == 0) { out = result; return true; }
shift += 7;
}
return false; // 10바이트 넘게 continuation → 손상/악성
}
bool ReadBytes(size_t n, const uint8_t*& p) {
if (!Remaining(n)) return false; // (D) declared ≤ remaining
p = buf_ + pos_; pos_ += n; return true;
}
const uint8_t* buf_; size_t len_; size_t pos_ = 0;
private:
};
bool ParseItems(const uint8_t* buf, size_t len, std::vector<Item>& out) {
out.clear();
SafeReader r(buf, len);
uint64_t count;
if (!r.ReadVarInt(count)) return false;
// (C) count 상한: 아이템 최소 크기로 본 수용 가능 개수 이하만
const uint64_t kMinItemBytes = 2; // id(>=1) + nameLen(>=1)
if (count > r.Left() / kMinItemBytes) return false;
out.reserve((size_t)count); // 이제 안전
for (uint64_t i = 0; i < count; ++i) {
uint64_t id64, nameLen;
if (!r.ReadVarInt(id64)) return false;
if (id64 > 0xFFFFFFFFull) return false; // uint32 범위 검증
if (!r.ReadVarInt(nameLen)) return false;
const uint8_t* p = nullptr;
if (!r.ReadBytes((size_t)nameLen, p)) return false; // (D) 정합성
Item it; it.id = (uint32_t)id64;
it.name.assign((const char*)p, (size_t)nameLen);
out.push_back(std::move(it));
}
return true;
}
포인트: (1) read 전에 항상
Remaining, (2) VarInt 10바이트·64비트 한도, (3) 개수/길이를 남은 버퍼로 상한 검증한 뒤에만 할당·복사, (4) 실패는 즉시 false 로 통째 거부.
C++ 구문 검증
g++ -std=c++17 -fsyntax-only problem.cpp 통과(문제 코드). 수정안도 동일 표준에서 컴파일됨.
더 나은 설계
1) 검증된 디코더 라이브러리 사용
- 직접 짠 VarInt 파서는 이런 함정의 온상. Protobuf/FlatBuffers 등은 경계·오버플로·최대 크기를 이미 처리한다. 트레이드오프: 의존성/포맷 고정 vs 직접 구현 위험.
2) 메시지 전체 상한(envelope limits)
- 프레이밍 단계에서 최대 메시지 크기, 최대 아이템 수, 최대 문자열 길이를 프로토콜 상수로 못 박고 그 이상은 프레이밍에서 컷. 디코더는 이미 한도 내 버퍼만 받는다.
3) reserve 정책
- 외부 개수로 즉시
reserve하지 않고, "아이템을 실제로 하나 읽을 때마다 push" 하거나 검증된 상한으로만 예약. 큰 입력은 청크 단위 증가.
4) UTF-8/내용 검증
name은 길이뿐 아니라 UTF-8 유효성·금칙 문자도 검증(여긴 별도 단계). 길이 검증과 내용 검증을 분리.
5) 퍼징
- 디코더는 libFuzzer/AFL 로 상시 퍼징 대상에 포함. OOB/UB 는 ASan/UBSan 으로 잡는다.
면접 포인트
- 핵심: 신뢰 경계에서의 입력 검증 — "read 전에 남았는지", "선언 길이 ≤ 실제 남은 길이", "외부 개수로 곧장 할당 금지", "가변 인코딩의 종료·범위 한도". 보안·안정성 사고의 단골.
- 예상 질문:
- "VarInt 가 무한루프/UB 에 빠지는 입력은?" →
0x80반복(continuation 만). 10바이트 한도와 shift>=64 차단 필요. - "
reserve(count)가 왜 위험한가?" → 외부가 준 개수로 메모리 선점 → 단 몇 바이트로 OOM 유발. 남은 버퍼 기반 상한 필요. - "길이 필드를 어떻게 신뢰하나?" → 신뢰하지 않는다. declared ≤ remaining 을 항상 검사.
- "VarInt 가 무한루프/UB 에 빠지는 입력은?" →
본 문제는 카탈로그 11.프로토콜에 새로 추가된 상황이며, 기존 protocol_version/problem1 (고정 길이프리픽스 스트림 부분수신)·problem4(패킷 검증 길이/시퀀스)·problem8(프래그먼트 재조립)과 결함 축이 다르다(가변길이 인코딩 자체의 종료·오버플로·할당 방어).