42. 기능 플래그(capability) 협상: 교집합 부재와 협상 결과의 잘못된 캐시 범위 (C#)
난이도 중내 리뷰 · 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회 추가.
- 기능 플래그와 프로토콜 버전의 역할 분리: 버전은 와이어 포맷 호환성, 플래그는 선택적 기능. 섞으면 "버전은 높은데 기능은 꺼진" 조합에서 분기 폭발이 일어난다.
- 협상 결과를 불변 객체로 세션에 봉인: 이후 어떤 코드도 캡을 바꿀 수 없게 한다.
면접 포인트
- 협상(negotiation)과 선언(declaration)의 차이. "클라가 지원한다고 했으니 켠다"가 왜 협상이 아닌지, TLS 암호 스위트 협상은 어떻게 하는지.
- 상태를 어느 생명주기에 붙일 것인가 — 연결 / 세션 / 계정 / 전역. 잘못된 범위에 붙은 캐시가 만드는 버그의 유형(오염, 무효화 실패, 크로스 디바이스 간섭).
- 예약 비트/미정의 필드를 "지금은 무해하니 통과"시키면 안 되는 이유. 프로토콜 확장성에서 must-ignore 와 must-reject 정책의 선택 기준.
해설 · C++
해설 — 기능 플래그(capability) 협상: 교집합 부재·캐시 범위 오류와 무검증 memcpy (C++)
난이도: 상
요약
C# 판과 같은 논리 결함(교집합 미적용, 계정 단위 캐시)에 더해, C++ 판은 신뢰할 수 없는 버퍼를
길이 검사 없이 memcpy 로 읽어 경계 밖 읽기(OOB read) 를 일으키고, 와이어 포맷을 호스트
바이트 순서·정렬 가정에 의존한다.
문제점
1) 길이 검사 없는 memcpy — 핸드셰이크 파싱 [경계 검증/OOB read]
- 증상: 3바이트짜리 핸드셰이크 패킷을 보내면
payload + 2에서 4바이트를 읽어 버퍼 밖을 건드린다. ASAN 에서 heap-buffer-overflow 로 즉시 잡히고, 실전에서는 인접 메모리 값이clientCaps로 들어와 비결정적으로 기능이 켜진다. - 재현 조건: 인증 전 연결에서
len < 6인 페이로드 전송. 원격에서 무조건 트리거 가능. - 근본 원인:
len인자를 받아놓고 한 번도 쓰지 않는다. 관리 언어라면 예외로 끝날 결함이 C++ 에서는 정보 노출·크래시로 이어진다.
2) 협상이 아닌 클라 선언의 무조건 수용 — (B) [입력 신뢰/권한]
- 증상:
kServerSupported가 선언만 되고 쓰이지 않아, 클라가CAP_VOICE_RELAY를 켜면encode가 미지원 경로(voiceRelayWrap)로 진입한다. - 재현 조건: 조작 클라가
clientCaps = 0xFFFFFFFF. - 근본 원인: 교집합 연산 부재. 알 수 없는 상위 비트 마스킹도 없다.
3) 협상 결과의 계정 단위 영구 캐시 — (A) [상태 범위/생명주기]
- 증상: 같은 계정이 다른 빌드의 클라로 접속하면 이전 협상값이 적용돼 해석 불가 패킷을 받는다.
서버를 롤백해도
cache_에 남은 신버전 협상값이 계속 적용된다. - 재현 조건: 계정 A 가 신버전 클라 접속 → 종료 → 구버전 클라 접속.
- 근본 원인: 협상은 연결의 속성인데 계정에 저장했다. 무효화 경로도, 서버 빌드 변경 시
폐기 로직도 없다.
cache_는 계정 수만큼 무한히 자라 메모리도 누수한다.
4) TOCTOU 성 이중 락 구간 — (A)+(B) [동시성]
- 조회 락과 삽입 락이 분리돼 있어, 같은 계정의 두 연결이 동시에 핸드셰이크하면 둘 다 캐시 미스로 진입해 서로 다른 값을 쓸 수 있다. 마지막 쓰기가 이긴다.
5) 와이어 포맷의 호스트 의존 — 파싱 [이식성]
memcpy로 호스트 바이트 순서 그대로 읽는다. 리틀엔디안 x86 에서만 우연히 동작하며, 와이어 포맷이 명시되지 않아 다른 아키텍처의 서버-서버 연동에서 깨진다.
6) enum Capability 를 비트 집합으로 사용 — [타입 안전성]
- 스코프 없는 열거형이 암시적으로 정수로 변환돼, 비트 연산 결과가 다시 열거형으로 검증 없이 오간다. 유효하지 않은 조합을 타입 시스템이 걸러주지 못한다.
수정안
constexpr std::uint32_t kKnownCapMask = 0x0Fu; // 현재 정의된 비트만
constexpr std::uint16_t kMinSupportedVersion = 12;
constexpr std::uint16_t kCurrentVersion = 18;
// 명시적 리틀엔디안 리더 (경계 검증 포함)
class WireReader {
public:
WireReader(const std::uint8_t* p, std::size_t n) : p_(p), n_(n) {}
bool readU16(std::uint16_t& out) {
if (n_ - off_ < 2) return false;
out = static_cast<std::uint16_t>(p_[off_]) |
static_cast<std::uint16_t>(p_[off_ + 1]) << 8;
off_ += 2;
return true;
}
bool readU32(std::uint32_t& out) {
if (n_ - off_ < 4) return false;
out = static_cast<std::uint32_t>(p_[off_]) |
static_cast<std::uint32_t>(p_[off_ + 1]) << 8 |
static_cast<std::uint32_t>(p_[off_ + 2]) << 16 |
static_cast<std::uint32_t>(p_[off_ + 3]) << 24;
off_ += 4;
return true;
}
private:
const std::uint8_t* p_;
std::size_t n_;
std::size_t off_ = 0;
};
bool CapabilityNegotiator::onHandshake(Session& session,
const std::uint8_t* payload, std::size_t len) {
WireReader r(payload, len);
std::uint16_t version = 0;
std::uint32_t clientCaps = 0;
if (!r.readU16(version) || !r.readU32(clientCaps)) return false; // (1)(5)
if (version < kMinSupportedVersion) return false; // 버전 게이트
session.clientProtocolVersion = std::min(version, kCurrentVersion);
// (2) 협상 = 알려진 비트 마스킹 → 서버 지원 집합과 교집합
std::uint32_t negotiated = (clientCaps & kKnownCapMask) & kServerSupported;
if (session.clientProtocolVersion < 15) negotiated &= ~CAP_BATCHED_EVENTS;
session.caps = negotiated; // (3) 세션에만 보관. 계정 캐시 제거
sendHandshakeAck(session, negotiated);
return true;
}
std::vector<std::uint8_t> CapabilityNegotiator::encode(
const Session& session, std::vector<std::uint8_t> body) {
const std::uint32_t caps = session.caps & kServerSupported; // 방어적 재확인
if (caps & CAP_LZ4) body = lz4Compress(std::move(body));
if (caps & CAP_VOICE_RELAY) body = voiceRelayWrap(std::move(body));
return body;
}
계정 단위 캐시(cache_, mutex_)는 삭제한다. 협상은 연결마다 다시 한다. 핸드셰이크는
연결당 1회뿐이라 캐시로 아낄 비용이 없고, 캐시가 만드는 오염 위험만 크다.
더 나은 설계
- 서버가 지원 집합을 먼저 광고 → 클라가 부분집합 선택: 클라가 없는 기능을 요청할 여지가 사라진다. 트레이드오프 — 왕복 1회 추가.
- 기능 식별자를 문자열/URN 으로(ALPN 방식): 비트 재할당 사고 방지, 로그 가독성. 트레이드오프 — 핸드셰이크 크기·파싱 비용 증가.
- 파싱 계층을
std::span기반 안전 리더로 일원화:memcpy직접 호출을 코드베이스에서 금지하고 모든 역직렬화가 경계 검증을 거치게 강제. 트레이드오프 — 약간의 추상화 비용 (인라인되면 사실상 0). enum class+ 전용 비트셋 타입: 암시적 정수 변환을 막아 검증되지 않은 값이 흘러다니지 못하게 한다.
면접 포인트
- 신뢰할 수 없는 입력을 파싱할 때 "필드를 읽기 전에 남은 바이트를 확인한다"가 왜 예외 없는 규칙인지. C++ 에서 이 규칙 위반이 예외가 아니라 UB 로 이어지는 이유.
- 협상 = 교집합. TLS 암호 스위트, HTTP content negotiation, ALPN 이 모두 같은 구조인 이유.
- 와이어 포맷을
memcpy+ 구조체로 다룰 때의 함정(엔디안, 패딩, 정렬)과 바이트 단위 명시적 시프트 인코딩이 안전한 이유.