22. 메시지 봉투 타입 태그 신뢰: 핸들러 인덱스/길이 미검증과 버퍼 오버리드
난이도 상해설 — 메시지 봉투 타입 태그 신뢰: 핸들러 인덱스/길이 미검증과 버퍼 오버리드
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
수신부가 와이어에서 받은 type 과 len 을 검증 없이 신뢰한다.
(A) type 을 핸들러 배열의 인덱스로 그대로 사용 → type >= 3 이면
IndexOutOfRangeException(서버 스레드 다운). (B)(C)(D) 각 핸들러는 payload 가 충분한지
확인하지 않고 고정 오프셋에서 값을 읽는다 → 버퍼가 짧으면 ArgumentOutOfRangeException
또는 (Chat 의 textLen 처럼) 인접 바이트를 읽어 길이를 부풀려 OOB 디코딩. 또한 len 이
종류가 기대하는 크기와 일치하는지 전혀 안 봐서, 짧은 payload 로 보낸 패킷이 인접 데이터를
필드 값으로 오독한다. 정답 한 줄: type 의 유효성(범위/허용목록)과 payload 길이를 읽기
전에 검증하고, 모든 필드 읽기를 경계 검사가 내장된 리더로 하라.
변별: problem4(패킷 길이/시퀀스/페이로드 검증)·problem6(RPC ID↔핸들러 매핑)·problem17 (가변길이 필드 과대 길이)와 인접하지만, 본 문제의 핵심은 타입 태그를 신뢰한 디스패치 (인덱스/핸들러 선택)와 종류별 고정 레이아웃 읽기에서의 타입 혼동·경계 미검증이다.
문제점
(A) _handlers[type] — 신뢰되지 않은 인덱스 (OOB / 타입 혼동) ★간판
- 분류 태그: untrusted index / unvalidated dispatch.
- 증상:
type이 0..2 밖이면 배열 인덱스 초과로 예외. enum 에 정의된 값만 와도, enum 정의 순서와 배열 순서가 어긋나면 엉뚱한 핸들러로 라우팅(타입 혼동: Move 를 Trade 로 해석). - 재현 조건:
type = 3..65535또는 핸들러 배열/enum 의 순서 불일치. - 근본 원인: 와이어 값을 허용 목록과 대조하지 않고 곧장 인덱스/디스패치 키로 사용.
(B)(C)(D) 길이 미검증 고정 오프셋 읽기 — 버퍼 오버리드 ★간판
- 분류 태그: out-of-bounds read / missing length validation.
- 증상:
len(과 실제buf.Length)을 보지 않고p+8,p+12등 고정 위치를 읽는다. payload 가 짧으면BitConverter가ArgumentOutOfRangeException을 던지거나(Move/Trade), Chat 처럼textLen을 버퍼에서 읽어 그 길이만큼GetString하면 인접 메모리/다음 패킷 바이트를 문자열로 오버리드·누출한다. - 재현 조건:
len을 작게(또는 0) 보내고 헤더만 채운 패킷,textLen을 거대하게. - 근본 원인: "선언된 길이"와 "실제 버퍼 길이"를 읽기 전에 대조하지 않음.
(추가) len 과 종류 기대 크기의 불일치 미검증 — 타입 혼동
- 분류 태그: type confusion.
- 증상: Trade 는 12바이트를 기대하는데
len=4인 패킷도 통과를 시도한다. 길이가 맞는지 보지 않으면, 짧은/긴 payload 가 다음 필드를 다른 의미로 오독한다. - 근본 원인: 종류별 고정 크기(또는 최소 크기) 검증 부재.
(추가) buf 최소 길이(4바이트 헤더) 미검증
BitConverter.ToUInt16(buf, 2)전에buf.Length >= 4를 확인하지 않으면 헤더 읽기에서부터 예외가 난다.
수정안 (정확한 코드)
읽기 전에 헤더 길이 → type 허용성 → payload 길이(종류 기대치) 순으로 검증하고, 경계 검사가 내장된 리더로 필드를 읽는다.
public bool Dispatch(byte[] buf)
{
if (buf == null || buf.Length < 4) return false; // 헤더 보장
ushort type = BitConverter.ToUInt16(buf, 0);
ushort len = BitConverter.ToUInt16(buf, 2);
// payload 가 실제 버퍼 안에 있는지(선언 길이 ≤ 남은 바이트)
if (4 + len > buf.Length) return false;
var payload = new ReadOnlySpan<byte>(buf, 4, len);
switch ((MsgType)type) // 명시적 허용 목록(switch)
{
case MsgType.Move: return HandleMove(payload);
case MsgType.Trade: return HandleTrade(payload);
case MsgType.Chat: return HandleChat(payload);
default: return false; // 알 수 없는 type 거부
}
}
private bool HandleMove(ReadOnlySpan<byte> p)
{
if (p.Length != 12) return false; // 종류 기대 크기 검증
float x = BinaryPrimitives.ReadSingleLittleEndian(p);
float y = BinaryPrimitives.ReadSingleLittleEndian(p.Slice(4));
float z = BinaryPrimitives.ReadSingleLittleEndian(p.Slice(8));
if (!IsFinite(x, y, z)) return false; // 값 정상성(NaN/Inf 방어)
ApplyMove(x, y, z);
return true;
}
private bool HandleChat(ReadOnlySpan<byte> p)
{
if (p.Length < 2) return false;
ushort textLen = BinaryPrimitives.ReadUInt16LittleEndian(p);
if (2 + textLen != p.Length) return false; // 선언 길이 == 실제 남은 길이
string text = System.Text.Encoding.UTF8.GetString(p.Slice(2, textLen));
ApplyChat(text);
return true;
}
핵심은 ① type 을 인덱스가 아니라 명시적 허용 목록(switch)으로 매핑(default 거부),
② 읽기 전에 길이 검증(헤더 4바이트 → 4+len ≤ buf.Length → 종류별 기대 크기),
③ 모든 읽기를 Span + BinaryPrimitives 처럼 경계·엔디안이 명시된 API로.
더 나은 설계 (+ 트레이드오프)
- 선언적 스키마/코드젠: 메시지 레이아웃을 IDL(FlatBuffers/Protobuf 등)로 정의하고 파서를 생성하면 수작업 오프셋 산술과 경계 실수를 제거한다. 트레이드오프: 빌드 파이프라인· 의존성 추가.
- 핸들러 등록을 딕셔너리로:
Dictionary<MsgType, Handler>로 두면 인덱스 OOB 가 구조적으로 불가능하고, 미등록 type 은 자연히 거부된다. - type↔기대 크기 표: 종류별 (최소)크기를 테이블로 관리해 디스패치 직전 한 곳에서 검증.
- 방어적 카운팅/레이트리밋: 잘못된 패킷 비율이 높은 연결은 끊거나 감속(악성 트래픽 방어).
- 값 정상성 검증: 좌표 NaN/Inf, 음수 길이/수량 등 도메인 불변식을 파싱 직후 검사 (protocol_version/problem14 와 연결).
면접 포인트
- "와이어에서 온 값으로 배열을 인덱싱/캐스팅하지 말라" — 모든 외부 입력은 검증 후 사용. type 은 허용 목록, 길이는 버퍼 경계와 종류 기대치 둘 다 대조.
- 타입 혼동(type confusion)이란 — 같은 바이트열을 다른 타입으로 해석하게 만드는 공격. "선언 길이 == 종류 기대 길이" 검증이 1차 방어선.
- 수작업 오프셋 파싱의 위험과,
Span/BinaryPrimitives·스키마 코드젠으로 경계·엔디안을 구조적으로 강제하는 방법.
해설 — 메시지 봉투 타입 태그 신뢰: 핸들러 인덱스/길이 미검증과 reinterpret_cast UB · C++
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
수신부가 와이어의 type/len 과 버퍼 크기 size 를 전혀 검증하지 않는다.
(B) handlers_[type] 는 type >= 3 이면 배열 밖 멤버 함수 포인터를 호출한다 — 단순
크래시를 넘어, 인접 메모리의 비트패턴을 함수 포인터로 호출하는 제어 흐름 탈취 위험.
(C)(D)(E) payload 를 reinterpret_cast 로 구조체로 해석하는데, ① payload 가 그만큼 길다는
보장이 없어 버퍼 오버리드, ② 정렬되지 않은 바이트를 MoveMsg*/int64_t 로 보는 것은
정렬·strict-aliasing 위반(UB), ③ Chat 의 textLen 을 신뢰해 그 길이만큼 문자열을 만들면
인접/다음 패킷 바이트 누출. size·len 은 한 번도 쓰이지 않는다. 정답 한 줄:
size/len 으로 경계를 먼저 검증하고, type 은 허용 목록으로 매핑하며, 역직렬화는
reinterpret_cast 가 아니라 바이트 단위 복사(memcpy)+경계 검사 리더로.
변별: problem4(길이/시퀀스 검증)·problem7(엔디안/정렬 가정)·problem17(가변길이 과대 길이) 와 인접하나, 본 문제의 간판은 타입 태그를 신뢰한 디스패치(OOB 함수 포인터)와 reinterpret_cast 타입 펀닝이다.
문제점
(B) handlers_[type] — 신뢰되지 않은 인덱스로 멤버 함수 포인터 호출 ★간판
- 분류 태그: out-of-bounds indexed call / control-flow hijack.
- 증상:
type이 0..2 밖이면handlers_배열 밖을 읽어 쓰레기 함수 포인터를 호출한다. (C# 은 예외로 끝나지만 C++ 은 UB — 크래시 또는 임의 주소로 점프.) enum/배열 순서 불일치 시 타입 혼동. - 재현 조건:
type = 3..65535. - 근본 원인: 와이어 값을 허용 목록과 대조하지 않고 인덱스로 사용.
(A)(C)(D)(E) reinterpret_cast 로 untrusted 바이트를 구조체로 — UB + 오버리드 ★간판
- 분류 태그: type punning / unaligned access / strict-aliasing 위반 / OOB read.
- 증상:
buf/p가 4·8바이트 정렬이라는 보장이 없는데uint16_t*/MoveMsg*/int64_t로 캐스트해 읽는다. 일부 아키텍처에선 정렬 폴트, 컴파일러 최적화 하에선 strict-aliasing UB. payload 길이를 안 봐서m->z(p+8..11),t->gold(p+8..11)가 버퍼 밖을 읽을 수 있다. Chat 은h->textLen만큼text를 넘겨 인접 메모리를 노출한다. - 재현 조건: 짧은 payload, 정렬되지 않은 수신 버퍼, 거대한
textLen. - 근본 원인: "와이어 바이트 == 메모리 레이아웃" 가정 + 경계 미검증.
(추가) size/len 전면 미사용, 종류 기대 크기 미검증
Dispatch(buf, size)가size를 안 쓴다.len이 종류 기대 크기와 같은지도 검증 안 함 → 타입 혼동·오버리드의 근본 통로.
수정안 (정확한 코드)
size 로 헤더·payload 경계를 먼저 보장하고, type 은 switch 허용 목록으로, 필드는
memcpy 로 복사해 정렬/aliasing 을 회피한다.
#include <cstring>
static bool ReadU16(const uint8_t* buf, size_t size, size_t off, uint16_t& out)
{
if (off + 2 > size) return false;
uint16_t v; std::memcpy(&v, buf + off, 2); // 정렬·aliasing 안전
out = v; // (와이어 LE 가정 시 필요하면 바이트스왑)
return true;
}
bool Dispatch(const uint8_t* buf, size_t size)
{
uint16_t type, len;
if (!ReadU16(buf, size, 0, type)) return false;
if (!ReadU16(buf, size, 2, len)) return false;
if (4 + size_t(len) > size) return false; // payload 가 버퍼 안에 있는가
const uint8_t* p = buf + 4;
switch (static_cast<MsgType>(type)) { // 허용 목록
case MsgType::Move: return HandleMove(p, len);
case MsgType::Trade: return HandleTrade(p, len);
case MsgType::Chat: return HandleChat(p, len);
default: return false; // 알 수 없는 type 거부
}
}
bool HandleTrade(const uint8_t* p, uint16_t len)
{
if (len != 12) return false; // 종류 기대 크기
int64_t targetId; int32_t gold;
std::memcpy(&targetId, p, 8);
std::memcpy(&gold, p + 8, 4);
ApplyTrade(targetId, gold);
return true;
}
bool HandleChat(const uint8_t* p, uint16_t len)
{
if (len < 2) return false;
uint16_t textLen; std::memcpy(&textLen, p, 2);
if (size_t(2) + textLen != len) return false; // 선언 길이 == 실제 payload 길이
ApplyChat(reinterpret_cast<const char*>(p + 2), textLen); // 길이는 검증됨
return true;
}
핵심: ① type → 허용 목록(switch/맵), ② size/len 경계 검증을 읽기 전에, ③ 필드는
reinterpret_cast 구조체 오버레이가 아니라 memcpy 로(정렬·strict-aliasing 안전, 엔디안
명시). 멤버 함수 포인터 테이블을 꼭 쓸 거면 if (type >= N) return false; 를 먼저.
더 나은 설계 (+ 트레이드오프)
- 스키마/코드젠(FlatBuffers/Cap'n Proto/Protobuf): 오프셋 산술과 경계 실수를 제거. FlatBuffers 는 verifier 로 OOB 를 구조적으로 막는다. 트레이드오프: 의존성·빌드.
- 핸들러를
unordered_map<MsgType, Handler>: 인덱스 OOB 가 불가능, 미등록 type 자연 거부. - type↔기대 크기 테이블: 디스패치 직전 한 곳에서 길이 검증.
- 경계 검사 리더(BoundedReader) 추상화:
ReadU16/ReadI64/ReadBytes가 항상 남은 길이를 검사하고 실패를 전파 → 핸들러마다 수작업 검사 중복 제거. - 악성 트래픽 방어: 파싱 실패율 높은 연결 감속/차단.
면접 포인트
reinterpret_cast로 네트워크 바이트를 구조체로 보는 것이 왜 위험한가 — 정렬·strict-aliasing UB + 경계 미보장. 안전한 길은memcpy+ 경계 검사 + 명시적 엔디안.- 외부에서 온
type으로 배열/함수 포인터 테이블을 인덱싱하면 OOB 호출(제어 흐름 탈취)로 번질 수 있다. 반드시 범위 검사 또는 맵 매핑. - "선언 길이 == 종류 기대 길이 == 실제 남은 버퍼" 3자 일치 검증이 타입 혼동·오버리드의 1차 방어선. 가능하면 스키마 코드젠으로 구조적 보장.