3. 접속 핸드셰이크에서의 프로토콜 버전 협상
난이도 상해설 — 접속 핸드셰이크에서의 프로토콜 버전 협상
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
인증 이전 단계인 핸드셰이크에서 신뢰할 수 없는 입력을 길이 검증 없이 BitConverter로 읽고(A),
길이 무검증 가변필드(nameLen)를 그대로 신뢰(C)해 ArgumentOutOfRangeException/오파싱이 나고,
버전 호환 판단 로직(B)이 빌드 번호를 잘못 끌어들여 요구사항("프로토콜 버전으로만 판단,
빌드는 무관")과 정반대로 동작한다. 부분 롤아웃 시 호환되는 구버전 클라를 막거나,
반대로 호환 안 되는 클라를 통과시키는 사고로 이어진다. 핸드셰이크는 공격 표면의 최전선이라
설계 결함이 곧 보안 사고다.
문제점
(A) 길이 미검증 BitConverter 읽기 + 엔디안 가정 — 보안/안정성/호환성
- 증상:
recvLen이 8바이트(고정 헤더)보다 작아도BitConverter.ToUInt16(recvBuf, 6)같은 호출이 그대로 실행된다. C#은 네이티브 오버리드 대신ArgumentOutOfRangeException/IndexOutOfRangeException을 던져 핸드셰이크 워커가 크래시(또는 세션 강제 종료). - 추가(엔디안):
BitConverter는 호스트 엔디안으로 변환한다. 와이어는 little-endian 으로 합의했는데 빅엔디안 머신(일부 ARM/콘솔 빌드)에서 돌면BitConverter.IsLittleEndian이 false라 정수가 뒤집힌다. 와이어 엔디안을 명시적으로 처리하지 않았다. - 재현조건: 공격자가 2~3바이트짜리 truncated 핸드셰이크를 보냄(인증 전이라 누구나 가능).
- 근본원인: 파싱 전에
recvLen >= 고정헤더크기를 확인하지 않음. 엔디안을 호스트에 의존.
(C) 가변 길이 이름 무검증 — 보안/안정성
- 증상:
Encoding.UTF8.GetString(recvBuf, 8, nameLen)이nameLen을 그대로 신뢰. 거대값이면recvBuf경계를 넘어ArgumentOutOfRangeException또는 (8+nameLen이 buffer를 넘으면) 인접 데이터까지 읽으려다 예외 → 세션 끊김. 음수가 될 수 없는 ushort라도 buffer 길이를 넘는지 검사가 빠졌다. - 재현조건:
nameLen = 0xFFFF로 보내고 실제 데이터는 짧게. - 근본원인:
8 + nameLen <= recvLen(그리고 상한nameLen <= kMaxName) 검증 부재. "길이 필드는 절대 신뢰하지 말라" 원칙 위반.
(B) 버전 호환 판단 로직 결함 — 프로토콜설계/정확성 (핵심)
요구사항: ProtocolVersion으로만 호환 판단, BuildNumber는 무관. 그런데 코드는:
if (protocolVersion != ServerInfo.ProtocolVersion) {
if (buildNumber != ServerInfo.ExpectedBuild) { 거부 }
// else: 빌드가 맞으면 그냥 통과 ← 모순
}
- 모순 1 (비호환 통과): 프로토콜이 다른데 빌드만 같으면 거부하지 않고 통과한다. 프로토콜이 다르면 직렬화가 안 맞아 이후 모든 패킷이 깨진다.
- 모순 2 ("정확히 같음"만 호환으로 봄):
!=단일 비교라 버전 범위(min..max) 협상이 없다. 부분 롤아웃에서 서버 v7 ↔ 클라 v6/v7이 공존하는데, v7 서버는 v6를 무조건 거부 → 카나리 중 대량 접속 실패. - 모순 3 (협상 결과 미저장): 통과해도 "이 세션은 어떤 버전으로 통신할지"를 정하지 않는다. 이후 직렬화가 어느 버전을 기준으로 할지 알 수 없다.
(D) 거부 사유/경로 빈약 — 유지보수/UX
- 모든 거부가
"Version mismatch"한 문장. 클라가 "업데이트 필요"인지 "서버가 구버전"인지 구분 못 함. - 거부 후
SendDisconnect가 실제로 비동기 전송을 끝내기 전에Close()하면 사유가 안 갈 수 있음(전송-종료 순서).
수정안
private const int kHeaderFixed = 8; // ushort + uint + ushort
private const int kMaxNameLen = 64;
private const ushort MinSupportedProtocol = 6;
public enum HandshakeResult { Ok, TooOld, TooNew, Malformed }
public void OnHandshake(Session s, byte[] recvBuf, int recvLen)
{
// (A) 고정 헤더 길이 검증 후에만 파싱
if (recvLen < kHeaderFixed) { Reject(s, HandshakeResult.Malformed); return; }
// 엔디안 명시: 와이어가 LE 이므로 LE 로 읽는다(빅엔디안 머신에서도 안전)
ushort cliVer = BinaryPrimitives.ReadUInt16LittleEndian(recvBuf.AsSpan(0));
uint build = BinaryPrimitives.ReadUInt32LittleEndian(recvBuf.AsSpan(2));
ushort nameLen = BinaryPrimitives.ReadUInt16LittleEndian(recvBuf.AsSpan(6));
// (C) 가변필드 길이 검증
if (nameLen > kMaxNameLen || kHeaderFixed + nameLen > recvLen)
{
Reject(s, HandshakeResult.Malformed); return;
}
// (B) 프로토콜 버전으로만, "범위"로 판단 — 빌드 번호는 호환성과 무관
if (cliVer < MinSupportedProtocol) { Reject(s, HandshakeResult.TooOld); return; }
if (cliVer > ServerInfo.ProtocolVersion) { Reject(s, HandshakeResult.TooNew); return; }
// 협상 결과 저장: 둘 다 이해하는 가장 높은 버전으로 통신
s.negotiatedProtocol = Math.Min(cliVer, ServerInfo.ProtocolVersion);
s.clientProtocol = cliVer;
s.clientName = Encoding.UTF8.GetString(recvBuf, kHeaderFixed, nameLen);
s.authorized = true;
OnHandshakeComplete(s);
}
MinSupportedProtocol .. ProtocolVersion범위로 하위호환 구간을 명시.- 협상 버전(
negotiatedProtocol)을 세션에 저장 → 이후 직렬화/파싱이 이 값으로 분기. - 거부는 사유별(TooOld/TooNew/Malformed)로 구분해 클라가 적절히 안내.
Reject는 사유 패킷 전송 완료(또는 linger) 후 종료하도록 순서 보장.
더 나은 설계 — 버전 협상 전략
- 버전은 "범위"로 협상한다: 클라가
[minVer, maxVer]를 보내고, 서버도 자신의 범위를 알고 있어 교집합의 최댓값을 합의. 교집합이 비면 거부. 단일==비교는 부분 롤아웃에서 항상 깨진다. - 무엇으로 판단하는가 = ProtocolVersion 하나로 단일화: BuildNumber(핫픽스/리소스 버전)는 호환성 판단에서 분리. 빌드는 텔레메트리/강제업데이트 정책 입력으로만 사용.
- 협상 결과를 세션 상태로 승격: 핸드셰이크에서 정한
negotiatedProtocol을 모든 직렬화가 참조. 패킷마다 버전을 싣지 않아 대역폭 절약. - 엔디안 명시:
BitConverter(호스트 의존) 대신BinaryPrimitives.Read*LittleEndian/BigEndian으로 와이어 엔디안을 코드에 못박는다. - 거부도 프로토콜이다: "업데이트 URL/최소버전/사유 코드"를 담은 구조화된 거부 응답을 정의.
- 핸드셰이크 하드닝: 길이 상한, 타임아웃(슬로로리스 방지), 속도 제한.
트레이드오프
- 범위 협상은 유연하지만 "동시에 지원할 버전 수"가 늘수록 직렬화 분기/테스트 매트릭스가 커진다 → 보통 N-2 같은 윈도를 정하고 그 밖은 강제 업데이트.
- 협상 버전을 세션에 저장하면 중계 노드는 패킷만으로 버전을 모른다.
면접 포인트
- "프로토콜 버전과 빌드 번호를 분리해야 하는 이유는? 무엇으로 호환을 판단하나?" → 호환은 와이어 포맷(ProtocolVersion)이 결정. 빌드는 같은 포맷에서 버그픽스/리소스 차이일 뿐.
- "카나리/부분 롤아웃에서 서버 v7, 클라 v6/v7이 섞여 있다. 핸드셰이크를 어떻게 설계하나?" → 범위 협상 + min 합의 + 협상 버전 세션 저장. 단일 == 비교의 위험 설명.
- "BitConverter 로 와이어 정수를 읽으면 무슨 문제가 있나?"
→ 호스트 엔디안 의존(
IsLittleEndian). 와이어 엔디안이 합의돼 있으면BinaryPrimitives로 명시.
내가 놓친 항목 (복습용)
- [ ] (A) 수신 길이 미검증 BitConverter → ArgumentOutOfRange + 엔디안 호스트 의존
- [ ] (C) nameLen 신뢰 → GetString 범위 초과 예외
- [ ] (B) 빌드번호를 호환 판단에 끌어들인 모순 + == 단일 비교(범위 협상 부재)
- [ ] 협상 버전 미저장(이후 직렬화 기준 불명)
- [ ] 거부 사유 미분류, 전송-종료 순서
해설 — 접속 핸드셰이크에서의 프로토콜 버전 협상
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
인증 이전 단계인 핸드셰이크에서 신뢰할 수 없는 입력을 무검증으로 구조체 캐스팅(A), 길이 무검증 가변필드 읽기(C)로 버퍼 오버리드/크래시가 나고, 버전 호환 판단 로직(B)이 빌드 번호를 잘못 끌어들여 요구사항("프로토콜 버전으로만 판단, 빌드는 무관")과 정반대로 동작한다. 부분 롤아웃 시 호환되는 구버전 클라를 막거나, 반대로 호환 안 되는 클라를 통과시키는 사고로 이어진다. 핸드셰이크는 공격 표면의 최전선이라 설계 결함이 곧 보안 사고다.
문제점
(A) 무검증 reinterpret_cast — 보안/안정성
- 증상:
recvLen이 헤더 크기보다 작아도hs->protocolVersion,hs->buildNumber,hs->nameLen을 읽는다 → 버퍼 오버리드(읽기 범위 초과), 미정의 동작. - 재현조건: 공격자가 2~3바이트짜리 truncated 핸드셰이크를 보냄. 인증 전이라 누구나 가능.
- 근본원인: 캐스팅 전에
recvLen >= sizeof(header)를 확인하지 않음.#pragma pack구조체라도 수신 길이 검증이 캐스팅의 전제여야 한다. (추가로 엔디안/정렬 가정도 잠재 문제: 네트워크 바이트오더 변환이 없다.)
(C) 가변 길이 이름 무검증 — 보안/안정성
- 증상:
s.clientName.assign(hs->name, hs->nameLen)이nameLen을 그대로 신뢰. 거대값이면recvBuf경계를 넘어 읽어 힙 오버리드 → 크래시 또는 인접 메모리 유출. - 재현조건:
nameLen = 0xFFFF로 보내고 실제 데이터는 짧게. - 근본원인:
nameLen <= recvLen - offsetof(C_Handshake, name)검증 부재. "길이 필드는 절대 신뢰하지 말라" 원칙 위반.
(B) 버전 호환 판단 로직 결함 — 프로토콜설계/정확성 (핵심)
요구사항: ProtocolVersion으로만 호환 판단, BuildNumber는 무관. 그런데 코드는:
if (hs->protocolVersion != SERVER_PROTOCOL_VERSION) {
if (hs->buildNumber != EXPECTED_BUILD) { 거부 }
// else: 빌드가 맞으면 그냥 통과 ← 모순
}
- 모순 1 (호환 클라 오거부 / 또는 비호환 통과): 프로토콜이 다른데 빌드만 같으면 거부하지 않고 통과한다. 프로토콜이 다르면 직렬화가 안 맞아 이후 모든 패킷이 깨진다. 반대로 프로토콜은 같은데(=호환) 빌드가 다르면, 바깥 if가 거짓이라 안쪽까지 안 가서 통과는 하지만 — 이는 우연이고, 의도가 코드에 안 드러남.
- 모순 2 ("정확히 같음"만 호환으로 봄):
!=단일 비교라 버전 범위(min..max) 협상이 없다. 부분 롤아웃에서 서버 v7 ↔ 클라 v6/v7이 공존하는데, v7 서버는 v6를 무조건 거부 → 카나리 중 대량 접속 실패. - 모순 3 (협상 결과 미저장): 통과해도 "이 세션은 어떤 버전으로 통신할지"를 정하지 않는다. 이후 직렬화가 어느 버전을 기준으로 할지 알 수 없다.
(D) 거부 사유/경로 빈약 — 유지보수/UX
- 모든 거부가
"Version mismatch"한 문장. 클라가 "업데이트 필요"인지 "서버가 구버전"인지 구분 못 함. - 거부 후
SendDisconnect가 실제로 비동기 전송을 끝내기 전에Close()하면 사유가 안 갈 수 있음(전송-종료 순서).
수정안
enum class HandshakeResult { Ok, TooOld, TooNew, Malformed };
void OnHandshake(Session& s, const uint8_t* recvBuf, size_t recvLen)
{
// (A) 헤더 길이 검증 후에만 해석
constexpr size_t kHeaderFixed = offsetof(C_Handshake, name); // name 직전까지
if (recvLen < kHeaderFixed) { Reject(s, HandshakeResult::Malformed); return; }
const C_Handshake* hs = reinterpret_cast<const C_Handshake*>(recvBuf);
// 엔디안 명시 (프로토콜이 LE/BE 중 무엇인지 합의해야 함)
uint16_t cliVer = le16toh(hs->protocolVersion);
uint16_t nameLen = le16toh(hs->nameLen);
// (C) 가변필드 길이 검증
if (nameLen > recvLen - kHeaderFixed || nameLen > kMaxNameLen) {
Reject(s, HandshakeResult::Malformed); return;
}
// (B) 프로토콜 버전으로만, "범위"로 판단 — 빌드 번호는 호환성과 무관
if (cliVer < MIN_SUPPORTED_PROTOCOL) { Reject(s, HandshakeResult::TooOld); return; }
if (cliVer > SERVER_PROTOCOL_VERSION) { Reject(s, HandshakeResult::TooNew); return; }
// 협상 결과 저장: 둘 다 이해하는 가장 높은 버전으로 통신
s.negotiatedProtocol = std::min(cliVer, SERVER_PROTOCOL_VERSION);
s.clientProtocol = cliVer;
s.clientName.assign(hs->name, nameLen);
s.authorized = true;
OnHandshakeComplete(s);
}
MIN_SUPPORTED_PROTOCOL .. SERVER_PROTOCOL_VERSION범위로 하위호환 구간을 명시.- 협상 버전(
negotiatedProtocol)을 세션에 저장 → 이후 직렬화/파싱이 이 값으로 분기. - 거부는 사유별(TooOld/TooNew/Malformed)로 구분해 클라가 적절히 안내.
Reject는 사유 패킷 전송 완료(또는 linger) 후 종료하도록 순서 보장.
더 나은 설계 — 버전 협상 전략
- 버전은 "범위"로 협상한다: 클라가
[minVer, maxVer]를 보내고, 서버도 자신의 범위를 알고 있어 교집합의 최댓값을 합의. 교집합이 비면 거부. 단일==비교는 부분 롤아웃에서 항상 깨진다. - 무엇으로 판단하는가 = ProtocolVersion 하나로 단일화: BuildNumber(핫픽스/리소스 버전)는 호환성 판단에서 분리. 빌드는 텔레메트리/강제업데이트 정책 입력으로만 사용.
- 협상 결과를 세션 상태로 승격: 핸드셰이크에서 한 번 정한
negotiatedProtocol을 모든 직렬화가 참조 (problem2의 버전 게이트와 연결). 패킷마다 버전을 싣지 않아 대역폭 절약. - 거부도 프로토콜이다: "업데이트 URL/최소버전/사유 코드"를 담은 구조화된 거부 응답을 정의. 운영 중 강제 업데이트 유도에 필수.
- 핸드셰이크 하드닝: 길이 상한, 타임아웃(핸드셰이크 안 보내고 점유하는 슬로로리스 방지), 속도 제한. 인증 전 단계라 가장 단단해야 한다.
트레이드오프
- 범위 협상은 유연하지만 "동시에 지원할 버전 수"가 늘수록 직렬화 분기/테스트 매트릭스가 커진다 → 보통 N-2 같은 윈도를 정하고 그 밖은 강제 업데이트.
- 협상 버전을 세션에 저장하면 중계 노드는 패킷만으로 버전을 모른다(problem2와 동일 트레이드오프).
면접 포인트
- "프로토콜 버전과 빌드 번호를 분리해야 하는 이유는? 무엇으로 호환을 판단하나?" → 호환은 와이어 포맷(ProtocolVersion)이 결정. 빌드는 같은 포맷에서 버그픽스/리소스 차이일 뿐.
- "카나리/부분 롤아웃에서 서버 v7, 클라 v6/v7이 섞여 있다. 핸드셰이크를 어떻게 설계하나?" → 범위 협상 + min 합의 + 협상 버전 세션 저장. 단일 == 비교의 위험 설명.
- "핸드셰이크가 인증 전 단계인데 보안상 무엇을 주의하나?" → 무검증 캐스팅/길이 신뢰 금지, 타임아웃·레이트리밋, 최소 입력 검증 후 파싱.
내가 놓친 항목 (복습용)
- [ ] (A) 수신 길이 미검증 reinterpret_cast → 오버리드
- [ ] (C) nameLen 신뢰 → 힙 오버리드
- [ ] (B) 빌드번호를 호환 판단에 끌어들인 모순 + == 단일 비교(범위 협상 부재)
- [ ] 협상 버전 미저장(이후 직렬화 기준 불명)
- [ ] 엔디안/정렬 가정, 거부 사유 미분류, 전송-종료 순서