45. 배치 메시지 스트림의 미지(unknown) 타입 처리 (C#)
난이도 상해설 — 배치 메시지 스트림의 미지(unknown) 타입 처리 (C#)
난이도: 상
요약
디코더가 각 메시지의 선언된 len 을 전진 크기로 사용하지 않고 고정 상수
KnownPayloadSize 만큼만 오프셋을 전진시킨다. 그 결과 (A) 미지 타입이나 크기가
다른 메시지를 만나면 오프셋이 실제 메시지 경계와 어긋나 이후 배치 전체가 desync 되고,
(B) len 을 남은 버퍼와 비교하지 않아 Slice(off, len) 에서 범위를 벗어나면
ArgumentOutOfRangeException(또는 손상된 payload 해석)이 난다.
문제점
-
(A) 전진 크기 오류 → 프레임 desync:
off += KnownPayloadSize;는 항상 8 을 더한다. 실제 메시지 길이는len이므로len != 8인 메시지(특히 서버가 모르는 새 타입, 또는 가변 길이 채팅)를 지나면 다음 메시지의 시작 위치가 어긋난다. 한 번 어긋나면 이후 모든 헤더가 payload 중간을 가리켜 배치 전체가 오해석된다. "미지 타입은 무시"라는 default 는 맞지만, len 만큼 건너뛰지 않은 것이 핵심 결함이다.- 재현: 신버전 클라가 배치 앞에 새 타입(len=16) 하나를 넣어 보냄 → 뒤 메시지 붕괴.
- 근본 원인: 메시지 경계를 선언 길이(len)가 아니라 처리 타입의 가정 크기로 계산.
-
(B) 길이 경계 미검증:
buf.Slice(off, len)전에off + len <= buf.Length를 확인하지 않는다. 악성/손상 입력이len을 크게 주면 예외로 연결이 끊기거나(가용성), 검증을 우회한 경로에선 인접 데이터를 payload 로 오해석한다.- 근본 원인: 신뢰 불가 길이를 경계 검사 없이 사용.
수정안
헤더와 payload 모두 경계 검사하고, 항상 len 만큼 전진한다.
public void Decode(ReadOnlySpan<byte> buf)
{
int off = 0;
while (off + HeaderSize <= buf.Length)
{
ushort type = (ushort)(buf[off] | (buf[off + 1] << 8));
ushort len = (ushort)(buf[off + 2] | (buf[off + 3] << 8));
off += HeaderSize;
if (len > buf.Length - off) // (B) 경계 검증
throw new ProtocolException("payload length exceeds buffer");
var payload = buf.Slice(off, len);
switch (type)
{
case 1: HandleMove(payload); break;
case 2: HandleChat(payload); break;
default: /* 미지 타입: len 만큼 건너뛴다 */ break;
}
off += len; // (A) 선언 길이만큼 전진
}
}
남은 바이트가 헤더보다 작으면(부분 프레임) 루프를 끝내고 다음 수신과 이어붙인다.
더 나은 설계
- 자기 기술적(self-describing) 프레이밍: 모든 메시지가 길이 프리픽스를 갖게 해 파서가 타입을 몰라도 안전히 스킵(전방 호환)하게 만든다 — protobuf 의 wire-type/length 규칙이 대표적.
- 엄격 vs 관용 모드: 미지 타입을 스킵(관용)할지 거부(엄격)할지 정책화하고, 메트릭으로 미지 타입 비율을 관측해 배포 회귀를 조기 감지.
- 최대 배치/메시지 크기 상한과 메시지 개수 상한으로 자원 남용 방지.
면접 포인트
- 전방 호환(forward compatibility)의 핵심은 "모르는 것을 길이 기준으로 안전히 건너뛰기".
- 프레임 경계는 처리 코드의 가정이 아니라 wire 상의 선언 길이로 결정해야 한다.
- 신뢰 불가 길이는 항상 남은 버퍼와 비교 후 사용.
해설 — 배치 메시지 스트림의 미지(unknown) 타입 처리 (C++)
난이도: 상
요약
디코더가 선언된 len 대신 고정 상수 kKnownPayloadSize 만큼만 오프셋을 전진시켜,
(A) len != 8 인 메시지(미지 타입/가변 길이)를 지나면 이후 배치가 desync 되고,
(B) len 을 남은 크기와 비교하지 않아 핸들러가 payload[0..len) 를 읽을 때
버퍼 밖 접근(OOB read, UB)이 발생한다.
문제점
- (A) 전진 크기 오류 → 프레임 desync:
off += kKnownPayloadSize;는 항상 8 을 더한다. 실제 경계는len이므로 크기가 다른 메시지 하나만 지나도 다음 헤더가 payload 중간을 가리켜 배치 전체가 붕괴한다. 전방 호환의 목적("모르는 타입 스킵")은 len 만큼 건너뛸 때만 성립한다. - (B) 길이 경계 미검증 → OOB:
payload = buf + off후 핸들러가len바이트를 읽는데off + len <= size를 확인하지 않는다. 손상/악성len이면 인접 힙/스택을 읽어 정보 유출·크래시(UB). C++ 에서 신뢰 불가 길이로 포인터를 진행시키는 것은 정의되지 않은 동작으로 직결된다.
수정안
void decode(const uint8_t* buf, size_t size) {
size_t off = 0;
while (off + kHeaderSize <= size) {
uint16_t type = static_cast<uint16_t>(buf[off] | (buf[off + 1] << 8));
uint16_t len = static_cast<uint16_t>(buf[off + 2] | (buf[off + 3] << 8));
off += kHeaderSize;
if (len > size - off) { // (B) 경계 검증(무부호 언더플로 없이)
onProtocolError();
return;
}
const uint8_t* payload = buf + off;
switch (type) {
case 1: handleMove(payload, len); break;
case 2: handleChat(payload, len); break;
default: /* 미지 타입: len 만큼 스킵 */ break;
}
off += len; // (A) 선언 길이만큼 전진
}
}
len > size - off 는 off <= size 가 보장된 상태에서 무부호 뺄셈 언더플로 없이
안전하다(먼저 off += kHeaderSize 했고 루프 조건이 off + kHeaderSize <= size).
더 나은 설계
- 길이 프리픽스 필수화(self-describing): 모든 메시지가 길이를 가져 파서가 타입을 몰라도 안전히 스킵. protobuf wire-format 의 length-delimited 규칙 참고.
- 경계 검사 헬퍼/뷰 타입:
std::span(C++20) 이나 얇은 Reader 로 남은 길이 검사를 중앙화해 실수 방지. - 상한 정책: 최대 배치 크기·메시지 수·payload 크기 제한으로 자원 남용 차단.
면접 포인트
- 전방 호환 = "모르는 것을 길이 기준으로 안전히 스킵". 처리 코드의 가정 크기로 전진 금지.
- 신뢰 불가 길이 + 포인터 산술 = UB/OOB. 무부호 뺄셈 언더플로까지 고려한 경계식.
- desync 한 번이 배치 전체를 오해석시키는 연쇄성.