28. 존재 비트맵(presence bitmap) 역직렬화의 경계·예약 비트 검증 결함 (C#)
난이도 중내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 존재 비트맵(presence bitmap) 역직렬화의 경계·예약 비트 검증 결함 (C#)
난이도: 중
요약
켜진 비트마다 고정 크기 필드를 읽는데, (1) 각 필드를 읽기 전에 버퍼 길이와 대조하지 않아 BitConverter/인덱서가 버퍼 밖을 건드리고, (2) 미정의/예약 비트(5..15)가 켜져도 거부하지 않아 오프셋이 전진하지 않고 파싱이 오염된다. 비트맵 2바이트 읽기도 길이 검사가 없다.
문제점
- [경계 미검증 → 예외 DoS] (A)
ApplyField(ref outState, i, buf, cur, sz);- 증상:
cur + sz > buf.Length여도 호출 →BitConverter.ToInt32/인덱서가ArgumentOutOfRangeException/IndexOutOfRangeException을 던진다. C++ 와 달리 메모리 누출은 없지만, 신뢰 불가 입력 한 줄로 수신 루프가 예외로 죽는 DoS. catch로 연결만 끊어도 매 패킷 예외 비용이 크다. - 재현:
buf = { 0x1F, 0x00 }(비트 0..4) + 페이로드 0바이트 → 15바이트를 읽으려다 예외. - 근본원인: 매 필드
off + sz <= buf.Length경계 검증 부재, 비트맵buf.Length >= 2미검증.
- 증상:
- [예약/미정의 비트 미검증] (B)
int sz = FieldSize[i];- 증상: 5..15 비트가 켜지면
sz==0,ApplyField의default:는 아무것도 안 하고cur += 0이라 오프셋이 전진하지 않는다. 같은 바이트가 다음 켜진 필드로 중복 해석되고, 미래에 그 슬롯이 정의되면 와이어 포맷이 조용히 깨진다. 미정의 필드는 길이를 몰라 건너뛸 수도 없다. - 근본원인: 알려진 비트만 허용(
mask & ~Known) == 0)하는 검증 부재.
- 증상: 5..15 비트가 켜지면
수정안
길이 검증 → 미정의 비트 거부 → 매 필드 경계 검증 → 잔여 바이트 정책.
public bool Parse(byte[] buf, out EntityState outState)
{
outState = default;
if (buf == null || buf.Length < 2) return false; // 비트맵 검증
int cur = 0;
ushort mask = (ushort)(buf[0] | (buf[1] << 8));
cur += 2;
const ushort Known = 0x001F; // 비트 0..4만 정의
if ((mask & ~Known) != 0) return false; // (B) 예약 비트 거부
for (int i = 0; i < Fields; i++)
{
if ((mask & (1 << i)) != 0)
{
int sz = FieldSize[i];
if (sz == 0 || cur + sz > buf.Length) return false; // (A) 경계 검증
ApplyField(ref outState, i, buf, cur, sz);
cur += sz;
}
}
return cur == buf.Length; // 잔여 바이트 거부(정책)
}
ReadOnlySpan<byte>+BinaryPrimitives.ReadInt32LittleEndian(span.Slice(cur, sz))를 쓰면 슬라이스가 경계를 강제하고 할당 없이 안전하다.
더 나은 설계
Span/BinaryPrimitives로 reader 일원화: 모든 읽기를span.Slice(cur, n)로 통과시키면 경계 검증이 슬라이스에 내장돼 누락이 사라진다. 엔디안도 명시적.- TLV 로 전방 호환: 미정의 필드를 스킵하려면 필드마다 길이를 붙인다(
protocol_version/problem25). 비트맵+고정크기는 콤팩트하나 확장 시 버전 협상이 필수. - 예외를 흐름 제어로 쓰지 않기: 파서는 불리언/Result 로 거부를 표현. 신뢰 불가 입력마다 예외를 던지면 GC·스택 언와인드 비용이 공격 표면이 된다.
- 잔여 바이트 엄격 검증: 보안 경계에선
cur==Length로 패딩/변조 거부.
면접 포인트
- 관리 언어에서 OOB가 "메모리 안전하지만 예외 DoS"로 바뀌는 점 — C++ 의 UB와 대비.
- "알 수 없는 비트 무시"가 왜 프레임 디싱크로 이어지는가(미정의 필드 길이 불명).
Span/BinaryPrimitives로 경계·엔디안을 구조적으로 강제하는 패턴.
해설 · C++
해설 — 존재 비트맵(presence bitmap) 역직렬화의 경계·예약 비트 검증 결함 (C++)
난이도: 중
요약
켜진 비트마다 고정 크기 필드를 읽는데, (1) 각 필드를 읽기 전에 남은 바이트와 대조하지 않아 버퍼 밖을 읽고(over-read), (2) 미정의/예약 비트(인덱스 5..15)가 켜져도 거부하지 않아 FIELD_SIZE[i]==0 인 채로 잘못된 목적지(nullptr)에 memcpy 하거나 파싱 흐름이 오염된다. 선두 비트맵 2바이트 읽기 자체도 len>=2 검사가 없다.
문제점
- [버퍼 오버리드] (A)
std::memcpy(fieldPtr(out,i), cur, sz);- 증상:
cur + sz > end여도 그대로memcpy→ 버퍼 밖(인접 힙) 읽기. 공격자가 비트는 많이 켜고 페이로드는 짧게 보내면 OOB read. C++에선 정의되지 않은 동작(UB) — 크래시 또는 인접 메모리 누출. - 재현:
buf = { 0x1F, 0x00 }(비트 0..4 켜짐) + 페이로드 0바이트. 4+4+4+2+1=15바이트를 읽으려다 2바이트 버퍼를 넘어선다. - 근본원인: 매 필드 읽기 전
cur + sz <= end경계 검증 부재. 비트맵 2바이트 읽기(cur[0]|cur[1]<<8)도len>=2미검증.
- 증상:
- [예약/미정의 비트 미검증] (B)
int sz = FIELD_SIZE[i];- 증상: 5..15 비트가 켜지면
sz==0.fieldPtr가nullptr을 돌려주고memcpy(nullptr, cur, 0)(0바이트라도 널 포인터 인자 자체가 UB)이며,cur += 0이라 오프셋이 전진하지 않아 동일 바이트가 다른 필드로 중복 해석되거나, 미래에 그 슬롯이 정의되면 와이어 포맷이 조용히 깨진다. 알 수 없는 비트를 "그냥 무시"하는 건 위험 — 미정의 필드는 길이를 모르므로 건너뛸 수조차 없다. - 근본원인: 신뢰 불가 비트맵에 알려진 비트만 허용(
mask & ~KNOWN==0) 하는 검증이 없음. 예약 비트는 거부해야 한다(또는 명시적 버전 협상).
- 증상: 5..15 비트가 켜지면
수정안
비트맵 길이 검증 → 미정의 비트 거부 → 매 필드 경계 검증 → (선택) 잔여 바이트 0 확인.
bool parse(const uint8_t* buf, size_t len, EntityState& out) {
if (len < 2) return false; // 비트맵 자체 검증
const uint8_t* cur = buf;
const uint8_t* end = buf + len;
uint16_t mask = (uint16_t)(cur[0] | (cur[1] << 8));
cur += 2;
constexpr uint16_t KNOWN = 0x001F; // 비트 0..4만 정의
if (mask & ~KNOWN) return false; // (B) 예약/미정의 비트 거부
for (int i = 0; i < FIELDS; i++) {
if (mask & (1u << i)) {
int sz = FIELD_SIZE[i];
if (sz == 0 || cur + sz > end) return false; // (A) 경계 검증
std::memcpy(fieldPtr(out, i), cur, (size_t)sz);
cur += sz;
}
}
return cur == end; // 잔여 바이트(트레일러) 거부 — 정책에 따라
}
더 나은 설계
- TLV/길이 프리픽스로 미래 확장: 미정의 필드를 "건너뛸 수 있게" 하려면 각 필드에 길이를 붙인다(TLV,
protocol_version/problem25). 그러면 모르는 필드도 길이만큼 스킵 가능 — 진짜 전방 호환. 비트맵+고정크기는 콤팩트하지만 확장 시 버전 게이트가 필수. - 명시적 버전 협상: 비트맵 폭(16/32)과 필드 카탈로그를 핸드셰이크에서 합의(
protocol_version/problem3). 예약 비트는 "현재 버전에서 0이어야 함"으로 강제. - 경계 검증 헬퍼 일원화:
need(n)같은 reader 헬퍼로 모든 읽기를cur+n<=end로 통과시키면 누락이 사라진다. 손으로 오프셋 더하는 코드는 항상 한 곳에서 검사. - 잔여 바이트 정책: 엄격(
cur==end)이면 변조/패딩 거부에 유리, 관대하면 전방 호환에 유리 — 보안 경계에선 엄격 권장.
면접 포인트
- presence-bitmap vs TLV vs 고정 스키마의 콤팩트함 ↔ 확장성 트레이드오프.
- "알 수 없는 비트를 무시"가 왜 위험한가(미정의 필드는 길이를 몰라 스킵 불가 → 프레임 디싱크).
- 신뢰 불가 입력 파싱의 3대 검증: 길이(남은 바이트), 도메인(알려진 비트/타입만), 종단(잔여 바이트) — 그리고 C++ OOB가 UB인 이유.