← 문제로

10. 델타 스냅샷 동기화 + 리플레이 방어 (종합)

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

해설 — 델타 스냅샷 동기화 + 리플레이 방어 (종합)

난이도: 최상

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

요약

두 방향을 한 번에 다룬다. 서버→클라 델타 스냅샷(baseline 대비 차분)과, 클라→서버 리플레이 방어(nonce + 타임스탬프 창). 골격 아이디어(acked baseline 델타, 시간 창 + nonce 셋)는 정석이지만, 분산/UDP/시간/멀티스레드/신뢰불가 환경의 디테일에서 다수의 정확성·보안·확장성 결함이 있다.

핵심 결함:

  • 델타: "클라가 실제 가진 baseline"이 아니라 서버가 마지막 받은 ack 로 델타를 만든다 — ack 손실/뒤바뀜 시 클라가 없는 baseline 으로 델타가 와 상태 발산(desync). history 가 무한 증식하고, 없는 baseline 접근 시 C#은 KeyNotFoundException 으로 크래시.
  • 리플레이: 부호 없는 시간 뺄셈 언더플로우로 창 검사 우회, 미래 타임스탬프 미차단, nonce 셋 무한 증식(메모리 DoS), MAC 검증 부재로 nonce/시간 변조 자유, 멀티스레드 race, 호스트 엔디안 의존.

문제점

(A) "없는 baseline" 접근 + history 무한 증식 — 정확성/메모리

  • 증상: history[ackedTick] 인덱서는 해당 tick 이 없으면 C++의 operator[](빈 객체 삽입)와 달리 KeyNotFoundException 을 던진다 → ack 가 가리키는 스냅샷이 이미 정리됐거나 도착 전이면 즉시 크래시. 또 history 에서 오래된 스냅샷을 절대 지우지 않아 메모리가 tick 마다 증가.
  • 근본원인: TryGetValue 대신 인덱서, baseline 부재 처리 없음, history GC 없음.

(핵심) ack 기반 델타의 baseline 합의 오류 — 정확성(분산 일관성)

  • 증상: 서버가 ackedTick(마지막으로 받은 ack)으로 델타를 만든다. 하지만 UDP 라 ack 가 유실/지연/역순으로 온다. 서버가 tick 100 을 baseline 으로 골라 델타를 보냈는데, 클라는 100 을 받은 적이 없으면(스냅샷 100 유실) 존재하지 않는 baseline 에 델타를 적용 → 발산. 반대로 ack 보다 더 최근을 클라가 받았어도 서버는 모른다.
  • 근본원인: "클라가 확실히 가진 baseline"의 합의 프로토콜 부재. 정석은 클라가 델타 적용 후 그 tick 을 ack 하고, 서버는 확정 ack 된 스냅샷만 baseline 후보로 쓰며, 델타에 실은 baseTick 을 클라가 검증("내가 그 baseline 있나?")한 뒤 적용. 없으면 full 스냅샷 요청.

(와이어) snapTick/baseTick 엔디안 — 호환성

  • BitConverter.GetBytes(cur.tick)호스트 엔디안으로 쓴다(P7 계열). 이기종 클라와 바이트 순서 합의가 명세되어 있지 않다. 또 수신측이 길이/baseTick 유효성 검증을 해야 한다.

(C) 시간 창 검사의 부호 없는 언더플로우 + 미래 차단 부재 — 보안

  • 증상: serverNowMs - clientTimeMs > kWindowMs 에서 둘 다 uint. clientTimeMs 가 serverNow 보다 크면(미래/롤오버) 뺄셈이 거대한 양수로 언더플로우 → 비교가 참이 되어 "만료"로 떨어지거나, 값에 따라 창을 우회. 미래 타임스탬프(공격자가 시계를 앞당김)는 차단 로직이 없다. uint ms 는 ~49.7일에 랩어라운드. (C# uint 산술은 기본 unchecked 라 C++과 동일하게 언더플로우가 조용히 일어난다.)
  • 재현조건: 공격자가 clientTimeMs 를 조작하거나, 클라/서버 시계 스큐.
  • 근본원인: 부호 없는 시간 차의 방향성 미고려, 미래 한계 미설정, 단조시계 미사용.

(D)+(시간) nonce 셋 무한 증식 — 메모리 DoS

  • 증상: seenNonces영원히 누적된다. 정상 트래픽만으로도 무한 증가, 공격자는 서로 다른 nonce 를 쏟아 OOM 유발. 시간 창(5s)이 있는데도 창 밖으로 나간 nonce 를 비우지 않는다.
  • 근본원인: 리플레이 방어를 "시간 창 + 그 창 내 nonce"로 결합해야 하는데, nonce 셋이 창과 분리되어 GC 가 없다. (sliding window 미구현.)

(B)/(C)/(D) MAC(인증) 검증 부재 + 길이 미검증 — 보안(근본)

  • 증상: 주석상 mac 이 있다지만 코드는 검증하지 않는다. nonce/clientTime/payload 가 전부 변조 가능 → 공격자가 새 nonce + 유효 시간으로 어떤 명령이든 위조. 리플레이 방어 이전에 위조 방어가 없다.Acceptlen < 12 검사 없이 BitConverter.ToUInt64(buf,0) 를 호출 → 짧은 패킷에서 ArgumentOutOfRangeException(P4/P8 계열 공통 결함).
  • 근본원인: 인증되지 않은 메타데이터로 보안 판단 + 입력 길이 미검증.

(멀티스레드) seenNonces / history race — 정확성/안전

  • 증상: seenNonces.Add/Contains, history 접근이 동기화 없이 멀티스레드면 HashSet/Dictionary스레드 안전하지 않아 자료구조 손상/InvalidOperationException. 또 Contains-then-Add 가 원자적이지 않아 TOCTOU 리플레이(동시에 같은 nonce 두 개가 둘 다 통과).
  • 근본원인: 공유 상태 동기화/원자성 부재.

수정안

리플레이: 인증 먼저 → 단조시계 → sliding-window nonce

private readonly object _lock = new object();

public bool Accept(byte[] buf, int len, long serverNowMs, ReadOnlySpan<byte> key)
{
    if (len < HeaderSize + MacLen) return false;                   // 길이 검증

    // 1) 인증 먼저 — 변조된 nonce/시간은 애초에 무의미
    if (!VerifyMac(buf, len, key)) return false;                   // HMAC/AEAD

    var sp = buf.AsSpan(0, len);
    ulong nonce    = BinaryPrimitives.ReadUInt64LittleEndian(sp);  // 엔디안 명시
    uint  clientMs = BinaryPrimitives.ReadUInt32LittleEndian(sp.Slice(8));

    // 2) 양방향 시간 창 (부호 있는 long 차로, 언더플로우 차단)
    long diff = serverNowMs - clientMs;
    if (diff > kWindowMs || diff < -kMaxFutureSkewMs) return false; // 과거/미래 모두 제한

    // 3) sliding-window nonce: 창 밖 nonce 는 만료로 제거 + 멀티스레드 원자성
    lock (_lock)
    {
        EvictExpired(serverNowMs);                                 // 창 밖 정리
        if (!seen.TryAdd(nonce, clientMs)) return false;           // check+insert 원자적, 리플레이
        expiryQueue.Enqueue((clientMs, nonce));                    // GC 용
    }
    return true;
}
  • 인증(MAC) → 시간 창(부호 있는, 미래도 제한) → nonce(창 한정 sliding window) 순서.
  • 시계는 가능하면 세션 단위 단조 카운터/시퀀스를 병행(벽시계 의존 축소).

델타: baseline 합의 + history GC + full 폴백

public byte[] BuildDelta(Snapshot cur)
{
    bool haveBase = history.TryGetValue(confirmedBaseTick, out var baseSnap); // (A) TryGetValue

    uint baseTick = haveBase ? confirmedBaseTick : 0u;             // 0 = full snapshot
    var ms = new MemoryStream();
    WriteU32LE(ms, cur.tick); WriteU32LE(ms, baseTick);            // 엔디안 명세

    if (haveBase) AppendDiff(ms, baseSnap.state, cur.state);
    else          AppendFull(ms, cur.state);                       // baseline 없으면 full

    history[cur.tick] = cur;
    PruneHistory(cur.tick);   // 오래되거나 ack 불가능한 것 제거 (메모리 상한)
    return ms.ToArray();
}
  • 클라는 델타 수신 시 baseTick 보유 여부를 검증, 없으면 full 재동기 요청.
  • 서버는 클라가 ack 한 tick만 baseline 으로, history 에 크기/개수 상한 + GC.

더 나은 설계

  • 델타 동기화: Quake3/Source 모델처럼 "서버는 각 클라가 ack 한 마지막 스냅샷에 대한 델타만 보내고, 클라는 적용 후 ack". baseline 부재 시 full 스냅샷. baseTick 을 와이어에 실어 self-validating. 스냅샷 history 는 RTT 기반 윈도우만 보관.
  • 리플레이 방어 표준형: AEAD(헤더를 AAD) + 시퀀스 번호 기반 sliding-window (IPsec/QUIC 의 anti-replay window). 순수 타임스탬프보다 시계 의존이 적다. nonce 가 필요하면 창과 결합해 GC.
  • 시간은 단조/서버권위: 클라 시각은 신뢰하지 말고 보조로만. 서버 단조시계 (Environment.TickCount64/Stopwatch) + 세션 epoch.
  • 멀티스레드: 세션별 단일 처리 스레드(actor) 또는 lock/ConcurrentDictionary 로 공유 상태 격리. TryAdd 로 check-then-insert TOCTOU 제거.
  • 버전·상태 합의: 스냅샷/델타 포맷에 schema version, 엔디안 명세, 길이/체크섬. 버전 공존 시 양쪽이 같은 baseline·포맷을 본다는 걸 ack/협상으로 보장.

트레이드오프

  • sliding-window(시퀀스) 는 타임스탬프보다 메모리/구현이 단순하고 시계 비의존이지만, 순서가 크게 뒤섞이는 채널에선 창 크기를 키워야 한다.
  • full 스냅샷 폴백은 대역폭 스파이크를 만들지만 desync 영구화를 막는다(안전장치).
  • 세션별 단일 스레드는 락을 없애지만 스레드 수/스케줄링 설계가 필요.

면접 포인트

  1. "델타 스냅샷에서 baseline 을 잘못 고르면 무슨 일이 나나? 어떻게 합의하나?" → 클라가 없는 baseline 에 델타 적용 → 영구 desync. C#은 history[] 인덱서가 없는 키에 KeyNotFoundException. 클라 ack 기반 + baseTick 와이어 검증 + 없으면 full 폴백. Quake3 델타 모델.
  2. "nonce 셋으로 리플레이를 막을 때 메모리는 어떻게 관리하나?" → 시간/시퀀스 창과 결합한 sliding-window anti-replay. 창 밖 nonce 는 만료. 무한 누적 금지.
  3. "serverNow - clientTime > window 가 왜 위험한가?" → uint 언더플로우로 창 우회/오판(C# uint 도 unchecked), 미래 타임스탬프 미차단, 49.7일 랩. 부호 있는 long 양방향 검사 + 단조시계. 그리고 그 전에 MAC 으로 시간 자체를 인증해야 함.

내가 놓친 항목 (복습용)

  • [ ] (A) history 인덱서로 없는 baseline 접근 → KeyNotFoundException + history 무한 증식(GC 없음)
  • [ ] ack 기반 baseline 합의 오류 → 클라가 없는 baseline 에 델타 적용 → 영구 desync
  • [ ] (C) uint 시간 뺄셈 언더플로우 + 미래 타임스탬프 미차단 + 49.7일 랩
  • [ ] (D) nonce 셋 무한 증식(창과 미결합) → 메모리 DoS
  • [ ] MAC 검증 부재 + Accept 길이 미검증 → nonce/시간/payload 전면 위조 + 짧은 패킷 예외
  • [ ] seenNonces/history 멀티스레드 race(비스레드세이프 컬렉션) + Contains-then-Add TOCTOU
  • [ ] BitConverter 호스트 엔디안 의존(와이어 엔디안 미명세)