37. 협상된 세션 버전을 무시한 고정 레이아웃 파싱 (C#)
난이도 중해설 — 협상된 세션 버전을 무시한 고정 레이아웃 파싱 (C#)
난이도: 중상
요약
파서는 핸드셰이크에서 정해 세션에 저장한 NegotiatedVersion을 전혀 참조하지 않고,
항상 v2 레이아웃(12바이트, inputFlags 포함)으로 읽는다(A). 그리고 소비 바이트도
언제나 12를 더한다(B). v1 클라이언트가 보낸 8바이트 패킷을 이렇게 읽으면
inputFlags가 다음 패킷의 바이트(또는 버퍼 밖) 를 읽고, pos += 12가 4바이트를
넘겨 소비해 이후 모든 패킷의 경계가 밀린다(프레임 desync). 협상은 했지만 그 결과를
파싱에 적용하지 않은 것이 결함이다.
문제점
- 분류: 협상된 프로토콜 버전 미적용(버전-무관 고정 레이아웃 파싱) → 오파싱 + 프레임 desync + 버퍼 오버리드.
- 증상:
- 필드 오염: v1 클라의 8바이트 패킷에서
inputFlags(offset+8)를 읽으면, 그 4바이트는 실제로는 다음 패킷의 seq 이거나 버퍼 경계 밖이다. 존재하지 않는 입력 플래그가 조작 명령처럼 처리되거나ArgumentException/OOB. - 프레임 desync(연쇄 붕괴):
pos += 12가 v1 패킷을 4바이트 과소비 → 다음 패킷 시작 오프셋이 4바이트 밀린다. 그 뒤 모든 패킷의 필드가 밀려서 완전히 엉뚱하게 해석된다. 한 번 어긋나면 연결 전체가 쓰레기. - 반대로 서버가 나중에 v3로 필드를 더 늘리면 v2 클라에도 같은 종류의 밀림이 재발.
- 필드 오염: v1 클라의 8바이트 패킷에서
- 재현 조건: 구버전 클라이언트가 접속하거나(부분 롤아웃/스토어 심사 지연으로 항상 발생), 서버가 앞서 배포된 상황. v2 클라만 있는 테스트에서는 안 보인다.
- 근본 원인: 협상 결과(
NegotiatedVersion)를 파싱 로직에 연결하지 않음. 버전 협상은 "무슨 레이아웃으로 읽고 쓸지"를 정하는 것인데, 파서가 그 값을 무시하고 최신 레이아웃을 하드코딩했다. 버전 협상이 사실상 무의미해진다.
수정안
세션의 협상 버전에 따라 레이아웃(필드 유무·소비 크기)을 분기한다.
public MoveInput Parse(Session session, byte[] buf, ref int pos)
{
var m = new MoveInput
{
Seq = BitConverter.ToInt32(buf, pos),
TickMs = BitConverter.ToInt32(buf, pos + 4),
};
int size = 8;
if (session.NegotiatedVersion >= 2) // v2+ 에서만 추가 필드
{
m.InputFlags = BitConverter.ToInt32(buf, pos + 8);
size = 12;
}
else
{
m.InputFlags = 0; // v1: 기본값
}
pos += size; // 실제 소비 크기만 전진 (경계 보존)
return m;
}
(경계 검사 pos + size <= buf.Length 는 별도로 항상 선행해야 한다.) 핵심은 파싱
레이아웃과 소비 크기를 협상 버전으로 결정해, v1 패킷은 8바이트만 읽고 8바이트만
소비하게 하는 것이다. 그래야 다음 패킷 경계가 유지된다.
더 나은 설계
- 자기서술 프레이밍(길이 접두사): 각 패킷 앞에 길이를 두면, 파서가 필드 개수를 몰라도 정확히 그만큼 소비하고 다음 경계로 넘어간다. 버전별 필드 유무와 무관하게 프레임 desync가 원천 차단된다(버전은 필드 해석에만 사용).
- 태그드 필드(Protobuf류): 필드를
(번호, 타입)태그로 인코딩하면 구버전은 모르는 필드를 건너뛰고, 신버전은 없는 필드에 기본값을 쓴다. 오프셋 하드코딩이 사라져 스키마 진화가 안전해진다. - 버전을 파싱 컨텍스트로 일급화: 협상 버전을 파서에 명시적으로 주입(예: 버전별 파서 전략 객체)해서 "버전을 잊고 최신으로 읽는" 실수를 구조적으로 막는다.
- 하위호환 테스트 매트릭스: (구/신 클라) × (구/신 서버) 조합을 CI에서 상시 검증.
면접 포인트
- "버전 협상을 했는데 왜 깨지나?" → 협상 값을 파싱에 적용하지 않으면 협상은 장식일 뿐. 파서가 세션 버전으로 레이아웃과 소비 크기를 결정해야 한다.
- "고정 오프셋 파싱의 위험?" → 필드 추가/버전 차이에서 오프셋이 밀려 프레임 전체가 desync. 길이 접두사나 태그드 인코딩으로 "소비 크기"를 데이터 자체가 알려주게 하라.
- "무중단 배포에서 특히 중요한 이유?" → 서버·클라가 독립적으로 롤아웃되어 구·신 버전이 항상 공존한다. 버전-인지 파싱은 부분 롤아웃의 전제 조건이다.
해설 — 협상된 세션 버전을 무시한 고정 레이아웃 파싱 (C++)
난이도: 중상
요약
파서는 세션에 저장된 negotiatedVersion을 전혀 참조하지 않고 항상 v2 레이아웃
(12바이트, inputFlags 포함)으로 읽고(A), 소비 바이트도 언제나 12를 더한다(B).
v1 클라이언트의 8바이트 패킷을 이렇게 읽으면 memcpy(&m.inputFlags, data+pos+8, 4)가
다음 패킷 바이트 또는 버퍼 밖(OOB read) 을 읽고, pos += 12가 4바이트를 과소비해
이후 모든 패킷 경계가 밀린다(프레임 desync). 협상은 했지만 파싱에 적용하지 않은 것이
근본 결함이다. len 인자를 받고도 경계 검사를 안 하는 문제까지 겹친다.
문제점
- 분류: 협상 버전 미적용(고정 레이아웃) → OOB read + 프레임 desync + 오파싱.
- 증상:
- OOB read: v1 패킷은 8바이트뿐인데 offset+8에서 4바이트를 읽어 버퍼 경계를
넘거나 다음 패킷 바이트를
inputFlags로 오인. ASan이면 heap-buffer-overflow. - 프레임 desync(연쇄 붕괴):
pos += 12가 v1 패킷을 4바이트 과소비 → 다음 패킷 시작이 4바이트 밀리고, 그 뒤 모든seq/tickMs가 어긋나 연결 전체가 쓰레기. len을 받고도pos+size <= len검사를 안 해, 마지막 패킷에서 특히 OOB가 쉽다.
- OOB read: v1 패킷은 8바이트뿐인데 offset+8에서 4바이트를 읽어 버퍼 경계를
넘거나 다음 패킷 바이트를
- 재현 조건: 구버전 클라 접속(부분 롤아웃/심사 지연으로 상시) 또는 서버 선배포. v2 클라만 있는 테스트에선 안 보인다.
- 근본 원인: 협상 결과(
negotiatedVersion)를 파싱 로직에 연결하지 않음. 버전 협상은 "어떤 레이아웃으로 읽을지"를 정하는 것인데, 파서가 최신 레이아웃을 하드코딩했다.
수정안
협상 버전으로 레이아웃과 소비 크기를 분기하고, 매 읽기 전에 경계를 검사한다.
bool parse(const Session& session, const uint8_t* data, std::size_t len,
std::size_t& pos, MoveInput& out) {
std::size_t size = (session.negotiatedVersion >= 2) ? 12 : 8;
if (pos + size > len) return false; // 경계 검사 먼저
out = MoveInput{};
std::memcpy(&out.seq, data + pos + 0, 4);
std::memcpy(&out.tickMs, data + pos + 4, 4);
if (session.negotiatedVersion >= 2)
std::memcpy(&out.inputFlags, data + pos + 8, 4); // v2+ 에서만
else
out.inputFlags = 0; // v1 기본값
pos += size; // 실제 소비 크기만 전진 (경계 보존)
return true;
}
핵심: 파싱 레이아웃과 소비 크기를 협상 버전으로 결정해 v1 패킷은 8바이트만 읽고
8바이트만 소비 → 다음 패킷 경계 유지. len 기반 경계 검사로 OOB도 차단.
더 나은 설계
- 자기서술 프레이밍(길이 접두사): 각 패킷 앞에 길이를 두면 파서가 필드 개수를 몰라도 정확히 그만큼 소비·다음 경계로 이동 → 프레임 desync 원천 차단. 버전은 필드 해석에만.
- 태그드 필드(Protobuf류):
(번호, 타입)태그로 구버전은 모르는 필드 스킵, 신버전은 없는 필드에 기본값. 오프셋 하드코딩 제거로 스키마 진화 안전. - 버전을 파싱 컨텍스트로 일급화: 버전별 파서 전략을 주입해 "버전 잊고 최신으로 읽기" 실수를 구조적으로 방지.
- 하위호환 매트릭스: (구/신 클라) × (구/신 서버) 조합을 CI로 상시 검증.
면접 포인트
- "버전 협상을 했는데 왜 깨지나?" → 협상 값을 파싱에 적용하지 않으면 무의미. 파서가 세션 버전으로 레이아웃·소비 크기를 결정해야 한다.
- "고정 오프셋 파싱의 위험(C++)?" → OOB read + 프레임 desync.
len경계 검사와 길이 접두사/태그드 인코딩으로 "소비 크기"를 데이터가 알려주게 하라. - "무중단 배포에서 왜 필수?" → 서버·클라가 독립 롤아웃되어 구·신 버전이 항상 공존. 버전-인지 파싱은 부분 롤아웃의 전제.