29. 비트 단위 스트림 리더의 경계·폭 검증 부재 (C#)
난이도 중내 리뷰 · 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 패턴으로의 전환.
- 개수/폭/총예산 상한 — "선언된 크기를 믿지 말라"는 역직렬화 원칙.
해설 · C++
해설 — 비트 단위 스트림 리더의 경계·폭 검증 부재 (C++)
난이도: 중상
요약
비트 패킹 스트림을 읽으면서 (1) 매 비트 접근 전 남은 비트를 검증하지 않아 buf_[byteIdx] 가 버퍼 밖을 읽고 — C++ 에선 out-of-bounds read = UB(크래시 아닐 수도, 인접 메모리 유출일 수도), (2) 와이어에서 온 개수/폭을 남은 비트와 대조하지 않아 작은 입력이 과대 읽기/대량 반복으로 폭주하며, (3) 폭이 큰 경우 v << 1 산술과 readBits(0) 경계가 애매하다.
문제점
- [비트 OOB / UB] (A)
int bit = (buf_[byteIdx] >> bitIdx) & 1;- 증상:
bitPos_가len_*8을 넘으면byteIdx >= len_이 되어 할당 범위 밖 읽기. 관리 언어의 예외와 달리 C++ 은 조용히 인접 힙을 읽어(정보 유출) 진행하거나 세그폴트한다.len_는 저장만 하고 한 번도 검사에 쓰지 않는다. - 재현:
count만 크게 쓰고 실제 바이트는 몇 개만 전송 → 곧 버퍼 밖. - 근본원인:
readBits진입 시bitPos_ + n <= len_*8검사 부재. 부족은 UB 가 아니라 "불완전 프레임"으로 반환해야 함.
- 증상:
- [신뢰 불가 개수/폭 증폭] (B)
uint32_t val = r.readBits(w);+for (i<count)- 증상:
count=255, 각w=31이면 최대 약 9188비트 필요 — 없으면 (A) 로 OOB.std::vector::push_back도 count 만큼 무검증 증가(할당 증폭). 폭 필드가 5비트라 0..31 로 우연히 제한되지만, 남은 비트 검증이 없으면 여전히 OOB. - 근본원인: 개수·폭·총 비트 예산 상한과 남은-바이트 대조 부재.
- 증상:
- [폭 경계 / 시프트 UB]
readBits(w)with 큰 w- 증상: 만약 폭 필드가 더 넓어
w >= 32이거나 음수면, 루프 상한/v<<1누적이 의도와 달라진다(여기 5비트라 w<=31 이라uint32_t시프트는 정의되지만, 일반화하면1u<<32류 UB 주의).readBits(0)은 커서 무전진.
- 증상: 만약 폭 필드가 더 넓어
수정안
readBits 가 남은 비트를 검사(부족하면 실패 신호). 파서는 개수·폭을 상한·남은 비트와 대조.
#include <optional>
class BitReader {
public:
BitReader(const uint8_t* buf, size_t len) : buf_(buf), bitLen_(len * 8), bitPos_(0) {}
size_t remaining() const { return bitLen_ - bitPos_; }
bool tryReadBits(int n, uint32_t& out) {
if (n < 0 || n > 32) return false; // 폭 범위 검증
if ((size_t)n > remaining()) return false; // (A) 남은 비트 검증 → UB 대신 실패
uint32_t v = 0;
for (int i = 0; i < n; i++) {
size_t byteIdx = bitPos_ >> 3;
int bitIdx = 7 - (int)(bitPos_ & 7);
v = (v << 1) | (uint32_t)((buf_[byteIdx] >> bitIdx) & 1);
bitPos_++;
}
out = v;
return true;
}
private:
const uint8_t* buf_;
size_t bitLen_, bitPos_;
};
std::optional<std::vector<Field>> parseSnapshot(const uint8_t* packet, size_t len) {
BitReader r(packet, len);
std::vector<Field> fields;
uint32_t count;
if (!r.tryReadBits(8, count)) return std::nullopt;
constexpr uint32_t kMaxFields = 128;
if (count > kMaxFields) return std::nullopt; // (B) 개수 상한
fields.reserve(count);
for (uint32_t i = 0; i < count; i++) {
uint32_t wRaw;
if (!r.tryReadBits(5, wRaw)) return std::nullopt;
int w = (int)wRaw;
if (w == 0 || w > 24) return std::nullopt; // 폭 도메인 검증
uint32_t val;
if (!r.tryReadBits(w, val)) return std::nullopt; // 남은 비트 자동 대조
fields.push_back({ w, val });
}
return fields;
}
더 나은 설계
- 실패는 값으로:
std::optional/에러코드로 프레임 폐기·세션 종료. 신뢰 불가 입력에 UB(OOB) 를 남기면 RCE/정보유출 표면(protocol_version/problem24,25). - 총 비트 예산 사전 검산:
count*(5+maxW)상한으로 명백히 불가능한 헤더 조기 거절,reserve도 상한 이후에. - 시프트 UB 주의: 폭이 워드 크기 이상이면
1u<<n·v<<1산술을uint64_t누적 또는 폭 상한으로 안전화. - 바이트 정렬/잔여 비트 규약: 프레임 끝 잔여 비트=0(패딩) 강제로 디싱크 조기 탐지.
면접 포인트
- C++ 에서 OOB read 가 예외가 아니라 UB(정보유출/크래시) 인 점 — 관리 언어와의 위험도 차이.
- 비트 커서 산술과, 접근 전 남은 비트 검증의 필요.
- "선언된 개수/폭을 믿지 말라" — 상한·남은 바이트 대조로 증폭·OOB 동시 차단.