← 문제로

42. 기능 플래그(capability) 협상: 교집합 부재와 협상 결과의 잘못된 캐시 범위 (C#)

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

해설 — 기능 플래그(capability) 협상: 교집합 부재와 협상 결과의 잘못된 캐시 범위 (C#)

난이도: 중상

요약

협상이란 본질적으로 양쪽 지원 집합의 교집합을 구하는 일인데, 이 코드는 클라가 보낸 집합을 그대로 채택한다. 게다가 연결 단위여야 할 협상 결과를 계정 단위로 영구 캐시해, 클라 업그레이드/ 다운그레이드와 서버 롤백을 따라가지 못한다.

문제점

1) 협상이 아니라 클라 선언의 무조건 수용 — (B) [입력 신뢰/권한]

  • 증상: 서버가 지원하지 않는 VoiceRelay(0x08) 를 클라가 켜서 보내면 session.Caps 에 그대로 들어가고, Encode 에서 VoiceRelay.Wrap 경로로 진입한다. 서버 v3.2 미만에서는 이 경로가 미구현/부분구현이라 예외 또는 클라가 해석 불가능한 페이로드가 나간다.
  • 재현 조건: 조작된 클라(또는 서버 롤백 후 접속한 신버전 클라)가 clientCaps = 0xFFFFFFFF.
  • 근본 원인: ServerSupported 상수가 선언만 되어 있고 어디에서도 사용되지 않는다. 협상의 정의(교집합)가 코드에 구현되지 않았다.

2) 미정의·예약 비트 미검증 — (B) [프로토콜 검증]

  • 증상: 0x10 Reserved 나 상위 미정의 비트가 켜진 채 세션에 보관된다. 나중에 그 비트에 새 기능을 배정하면, 옛 클라가 우연히(또는 의도적으로) 켠 값 때문에 신기능이 잘못 활성화된다.
  • 재현 조건: 현재는 무해해 보이지만, 다음 릴리스에서 0x10 을 실제 기능에 할당하는 순간 구버전 트래픽이 그 기능 경로로 들어간다.
  • 근본 원인: 알 수 없는 비트를 거부하거나 마스킹하지 않고 그대로 통과시킨다.

3) 협상 결과의 캐시 범위가 계정 단위 — (A) [상태 범위/생명주기]

  • 증상: PC(신버전)로 접속했다가 모바일(구버전)로 접속하면, 구버전 클라에 신버전 협상값이 적용돼 해석 못 하는 압축/배치 패킷을 받고 즉시 연결이 끊긴다. 반대로 클라를 업데이트해도 옛 협상값이 계속 적용돼 신기능이 켜지지 않는다.
  • 재현 조건: 같은 계정으로 서로 다른 빌드의 클라이언트가 순차 접속.
  • 근본 원인: _cache 가 static 이며 키가 AccountId 다. 협상은 이 연결에서 합의한 값 인데 계정이라는 더 긴 생명주기에 붙였다. 무효화(만료·서버 버전 변경 시 폐기) 로직도 없다.

4) 캐시 경로가 버전을 무시 — (A) [버전 게이트]

  • 캐시 히트 시 version 을 읽기만 하고 검증하지 않는다. 최소 지원 버전 미만의 클라가 캐시된 최신 협상값으로 서비스를 받게 된다.

5) 페이로드 길이 미검증 — [경계 검증]

  • payload.Slice(0, 2), Slice(2, 4) 는 최소 6바이트를 가정한다. 짧은 페이로드에 대해 ArgumentOutOfRangeException 이 나며, 이는 인증 전 경로에서 원격 트리거 가능한 예외다.

수정안

public sealed class CapabilityNegotiator
{
    public const Capabilities ServerSupported =
        Capabilities.DeltaSnapshot | Capabilities.Lz4Compress | Capabilities.BatchedEvents;

    private const ushort MinSupportedVersion = 12;
    private const ushort CurrentVersion = 18;
    private const uint KnownCapMask = 0x0Fu;   // 현재 정의된 비트만

    public bool OnHandshake(Session session, ReadOnlySpan<byte> payload)
    {
        if (payload.Length < 6) return false;                      // (5) 길이 선검증

        ushort version = BinaryPrimitives.ReadUInt16LittleEndian(payload);
        uint clientCaps = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(2));

        if (version < MinSupportedVersion) return false;           // (4) 버전 게이트
        session.ClientProtocolVersion = Math.Min(version, CurrentVersion);

        // (1)(2) 협상 = 알려진 비트로 마스킹한 뒤 서버 지원 집합과 교집합
        var negotiated = (Capabilities)(clientCaps & KnownCapMask) & ServerSupported;

        // 버전 게이트가 걸린 기능은 추가로 제거
        if (session.ClientProtocolVersion < 15)
            negotiated &= ~Capabilities.BatchedEvents;

        session.Caps = negotiated;                                 // (3) 세션에만 저장, 캐시 없음
        SendHandshakeAck(session, negotiated);                     // 합의된 값을 클라에 회신
        return true;
    }
}

핵심은 & 두 번이다. 알려진 비트로 마스킹(미래 비트 차단) → 서버 지원 집합과 교집합 (권한 차단). 그리고 합의 결과를 ACK 로 되돌려 보내 양쪽이 같은 집합을 쓰게 한다.

Encode 도 세션 협상값만 신뢰하되, 서버 지원 집합을 최종 방어선으로 한 번 더 확인한다.

public byte[] Encode(Session session, byte[] body)
{
    var caps = session.Caps & ServerSupported;   // 방어적 재확인
    if ((caps & Capabilities.Lz4Compress) != 0) body = Lz4.Compress(body);
    if ((caps & Capabilities.VoiceRelay) != 0)  body = VoiceRelay.Wrap(body);
    return body;
}

더 나은 설계

  • 기능 목록을 문자열/URN 집합으로 교환(TLS ALPN, WebSocket subprotocol 방식): 비트 재할당 사고가 원천 차단되고 로그 가독성이 좋다. 트레이드오프 — 핸드셰이크 크기 증가, 파싱 비용.
  • 서버가 먼저 지원 집합을 광고하고 클라가 그 부분집합을 고르는 방향: 클라가 서버에 없는 기능을 요청할 여지 자체가 사라진다. 트레이드오프 — 왕복 1회 추가.
  • 기능 플래그와 프로토콜 버전의 역할 분리: 버전은 와이어 포맷 호환성, 플래그는 선택적 기능. 섞으면 "버전은 높은데 기능은 꺼진" 조합에서 분기 폭발이 일어난다.
  • 협상 결과를 불변 객체로 세션에 봉인: 이후 어떤 코드도 캡을 바꿀 수 없게 한다.

면접 포인트

  1. 협상(negotiation)과 선언(declaration)의 차이. "클라가 지원한다고 했으니 켠다"가 왜 협상이 아닌지, TLS 암호 스위트 협상은 어떻게 하는지.
  2. 상태를 어느 생명주기에 붙일 것인가 — 연결 / 세션 / 계정 / 전역. 잘못된 범위에 붙은 캐시가 만드는 버그의 유형(오염, 무효화 실패, 크로스 디바이스 간섭).
  3. 예약 비트/미정의 필드를 "지금은 무해하니 통과"시키면 안 되는 이유. 프로토콜 확장성에서 must-ignore 와 must-reject 정책의 선택 기준.