← 문제로

3. 접속 핸드셰이크에서의 프로토콜 버전 협상

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 접속 핸드셰이크에서의 프로토콜 버전 협상

난이도: 상

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

인증 이전 단계인 핸드셰이크에서 신뢰할 수 없는 입력을 길이 검증 없이 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 같은 윈도를 정하고 그 밖은 강제 업데이트.
  • 협상 버전을 세션에 저장하면 중계 노드는 패킷만으로 버전을 모른다.

면접 포인트

  1. "프로토콜 버전과 빌드 번호를 분리해야 하는 이유는? 무엇으로 호환을 판단하나?" → 호환은 와이어 포맷(ProtocolVersion)이 결정. 빌드는 같은 포맷에서 버그픽스/리소스 차이일 뿐.
  2. "카나리/부분 롤아웃에서 서버 v7, 클라 v6/v7이 섞여 있다. 핸드셰이크를 어떻게 설계하나?" → 범위 협상 + min 합의 + 협상 버전 세션 저장. 단일 == 비교의 위험 설명.
  3. "BitConverter 로 와이어 정수를 읽으면 무슨 문제가 있나?" → 호스트 엔디안 의존(IsLittleEndian). 와이어 엔디안이 합의돼 있으면 BinaryPrimitives로 명시.

내가 놓친 항목 (복습용)

  • [ ] (A) 수신 길이 미검증 BitConverter → ArgumentOutOfRange + 엔디안 호스트 의존
  • [ ] (C) nameLen 신뢰 → GetString 범위 초과 예외
  • [ ] (B) 빌드번호를 호환 판단에 끌어들인 모순 + == 단일 비교(범위 협상 부재)
  • [ ] 협상 버전 미저장(이후 직렬화 기준 불명)
  • [ ] 거부 사유 미분류, 전송-종료 순서