10. 델타 스냅샷 동기화 + 리플레이 방어 (종합)
난이도 최상내 리뷰 · C#
내 리뷰 · 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 + 유효 시간으로 어떤 명령이든 위조. 리플레이 방어 이전에 위조 방어가 없다. 또Accept가len < 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 영구화를 막는다(안전장치).
- 세션별 단일 스레드는 락을 없애지만 스레드 수/스케줄링 설계가 필요.
면접 포인트
- "델타 스냅샷에서 baseline 을 잘못 고르면 무슨 일이 나나? 어떻게 합의하나?"
→ 클라가 없는 baseline 에 델타 적용 → 영구 desync. C#은
history[]인덱서가 없는 키에KeyNotFoundException. 클라 ack 기반 + baseTick 와이어 검증 + 없으면 full 폴백. Quake3 델타 모델. - "nonce 셋으로 리플레이를 막을 때 메모리는 어떻게 관리하나?" → 시간/시퀀스 창과 결합한 sliding-window anti-replay. 창 밖 nonce 는 만료. 무한 누적 금지.
- "
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 호스트 엔디안 의존(와이어 엔디안 미명세)
해설 · C++
해설 — 델타 스냅샷 동기화 + 리플레이 방어 (종합)
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
두 방향을 한 번에 다룬다. 서버→클라 델타 스냅샷(baseline 대비 차분)과, 클라→서버 리플레이 방어(nonce + 타임스탬프 창). 골격 아이디어(acked baseline 델타, 시간 창 + nonce 셋)는 정석이지만, 분산/UDP/시간/멀티스레드/신뢰불가 환경의 디테일에서 다수의 정확성·보안·확장성 결함이 있다.
핵심 결함:
- 델타: "클라가 실제 가진 baseline"이 아니라 서버가 마지막 받은 ack 로 델타를 만든다 — ack 손실/뒤바뀜 시 클라가 없는 baseline 으로 델타가 와 상태 발산(desync). history 가 무한 증식하고, 없는 baseline 접근으로 오작동/크래시.
- 리플레이: 부호 없는 시간 뺄셈 언더플로우로 창 검사 우회, 미래 타임스탬프 미차단, nonce 셋 무한 증식(메모리 DoS), MAC 검증 부재로 nonce/시간 변조 자유, 멀티스레드 race.
문제점
(A) "없는 baseline" 접근 + history 무한 증식 — 정확성/메모리
- 증상:
history[ackedTick]는operator[]라 해당 tick 이 없으면 빈 Snapshot 을 삽입하고 그걸 base 로 쓴다 → 빈 상태와의 델타 = 전체가 변경된 것처럼 인코딩되거나, 더 나쁘게는 클라가 가진 baseline 과 불일치 → 영구 desync. 또history에서 오래된 스냅샷을 절대 지우지 않아 메모리가 tick 마다 증가. - 근본원인:
at()/find()대신operator[], 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 엔디안·정렬 — 호환성
memcpy(&cur.tick, ...)자체는 정렬 안전하나, 엔디안 명세가 없다(P7 계열). 이기종 클라와 바이트 순서 합의 필요. 또 수신측이 길이/baseTick 유효성 검증을 해야 한다.
(C) 시간 창 검사의 부호 없는 언더플로우 + 미래 차단 부재 — 보안
- 증상:
serverNowMs - h->clientTimeMs > kWindowMs에서 둘 다 uint32. clientTimeMs 가 serverNow 보다 크면(미래/롤오버) 뺄셈이 거대한 양수로 언더플로우 → 비교가 참이 되어 "만료"로 떨어지거나, 값에 따라 창을 우회. 미래 타임스탬프(공격자가 시계를 앞당김)는 차단 로직이 없다. uint32 ms 는 ~49.7일에 랩어라운드. - 재현조건: 공격자가 clientTimeMs 를 조작하거나, 클라/서버 시계 스큐.
- 근본원인: 부호 없는 시간 차의 방향성 미고려, 미래 한계 미설정, 단조시계 미사용.
(D)+(시간) nonce 셋 무한 증식 — 메모리 DoS
- 증상:
seenNonces는 영원히 누적된다. 정상 트래픽만으로도 무한 증가, 공격자는 서로 다른 nonce 를 쏟아 OOM 유발. 시간 창(5s)이 있는데도 창 밖으로 나간 nonce 를 비우지 않는다. - 근본원인: 리플레이 방어를 "시간 창 + 그 창 내 nonce"로 결합해야 하는데, nonce 셋이 창과 분리되어 GC 가 없다. (sliding window 미구현.)
(B)/(C)/(D) MAC(인증) 검증 부재 — 보안(근본)
- 증상: 주석상
mac이 있다지만 코드는 검증하지 않는다. nonce/clientTime/payload 가 전부 변조 가능 → 공격자가 새 nonce + 유효 시간으로 어떤 명령이든 위조. 리플레이 방어 이전에 위조 방어가 없다. AEAD/HMAC 로 헤더+payload 무결성을 먼저 검증해야 nonce/시간이 의미를 가진다. - 근본원인: 인증되지 않은 메타데이터로 보안 판단.
(멀티스레드) seenNonces / history race — 정확성/안전
- 증상:
seenNonces.insert/count,history접근이 동기화 없이 멀티스레드면 자료구조 손상/UB. 또 check-then-insert 가 원자적이지 않아 TOCTOU 리플레이 (동시에 같은 nonce 두 개가 둘 다 통과). - 근본원인: 공유 상태 동기화/원자성 부재.
(A)(C)(D) 길이/포인터 검증 — 메모리 안전
Accept가len < sizeof(CmdHeader)검사 없이reinterpret_cast→ 짧은 패킷에서 오버리드. (P4/P8 계열 공통 결함.)
수정안
리플레이: 인증 먼저 → 단조시계 → sliding-window nonce
bool Accept(const uint8_t* buf, size_t len, uint64_t serverNowMs, const Key& key)
{
if (len < sizeof(CmdHeader) + MAC_LEN) return false; // 길이 검증
// 1) 인증 먼저 — 변조된 nonce/시간은 애초에 무의미
if (!VerifyMac(buf, len, key)) return false; // HMAC/AEAD
CmdHeader h; std::memcpy(&h, buf, sizeof(h)); // 엔디안 정규화 가정
uint64_t clientMs = NormalizeLE(h.clientTimeMs); // 가능하면 64-bit 권장
// 2) 양방향 시간 창 (부호 있는 차로, 언더플로우 차단)
int64_t diff = int64_t(serverNowMs) - int64_t(clientMs);
if (diff > kWindowMs || diff < -kMaxFutureSkewMs) return false; // 과거/미래 모두 제한
// 3) sliding-window nonce: 창 밖 nonce 는 만료 큐로 제거
std::lock_guard<std::mutex> lk(mtx_); // 멀티스레드 원자성
EvictExpired(serverNowMs); // (창 밖 정리)
auto [it, inserted] = seen_.emplace(h.nonce, clientMs); // check+insert 원자적
if (!inserted) return false; // 리플레이
expiryQueue_.push({clientMs, h.nonce}); // GC 용
return true;
}
- 인증(MAC) → 시간 창(부호 있는, 미래도 제한) → nonce(창 한정 sliding window) 순서.
- 시계는 가능하면 세션 단위 단조 카운터/시퀀스를 병행(벽시계 의존 축소).
델타: baseline 합의 + history GC + full 폴백
std::vector<uint8_t> BuildDelta(const Snapshot& cur) {
auto it = history.find(confirmedBaseTick); // (A) 확정 ack 된 것만, find 사용
bool haveBase = (it != history.end());
std::vector<uint8_t> out;
uint32_t baseTick = haveBase ? confirmedBaseTick : 0; // 0 = full snapshot
PutU32LE(out, cur.tick); PutU32LE(out, baseTick); // 엔디안 명세
if (haveBase) AppendDiff(out, it->second.state, cur.state);
else AppendFull(out, cur.state); // baseline 없으면 full
history[cur.tick] = cur;
PruneHistory(cur.tick); // 오래되거나 ack 불가능한 것 제거 (메모리 상한)
return out;
}
- 클라는 델타 수신 시 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.
- 시간은 단조/서버권위: 클라 시각은 신뢰하지 말고 보조로만. 서버 단조시계 + 세션 epoch.
- 멀티스레드: 세션별 단일 처리 스레드(actor) 또는 락/lock-free 자료구조로 공유 상태 격리.
- 버전·상태 합의: 스냅샷/델타 포맷에 schema version, 엔디안 명세, 길이/체크섬. 버전 공존 시 양쪽이 같은 baseline·포맷을 본다는 걸 ack/협상으로 보장.
트레이드오프
- sliding-window(시퀀스) 는 타임스탬프보다 메모리/구현이 단순하고 시계 비의존이지만, 순서가 크게 뒤섞이는 채널에선 창 크기를 키워야 한다.
- full 스냅샷 폴백은 대역폭 스파이크를 만들지만 desync 영구화를 막는다(안전장치).
- 세션별 단일 스레드는 락을 없애지만 스레드 수/스케줄링 설계가 필요.
면접 포인트
- "델타 스냅샷에서 baseline 을 잘못 고르면 무슨 일이 나나? 어떻게 합의하나?" → 클라가 없는 baseline 에 델타 적용 → 영구 desync. 클라 ack 기반 + baseTick 와이어 검증 + 없으면 full 폴백. Quake3 델타 모델.
- "nonce 셋으로 리플레이를 막을 때 메모리는 어떻게 관리하나?" → 시간/시퀀스 창과 결합한 sliding-window anti-replay. 창 밖 nonce 는 만료. 무한 누적 금지.
- "
serverNow - clientTime > window가 왜 위험한가?" → uint 언더플로우로 창 우회/오판, 미래 타임스탬프 미차단, 49.7일 랩. 부호 있는 양방향 검사 + 단조시계. 그리고 그 전에 MAC 으로 시간 자체를 인증해야 함.
내가 놓친 항목 (복습용)
- [ ] (A) history operator[] 로 없는 baseline 삽입 + history 무한 증식(GC 없음)
- [ ] ack 기반 baseline 합의 오류 → 클라가 없는 baseline 에 델타 적용 → 영구 desync
- [ ] (C) uint 시간 뺄셈 언더플로우 + 미래 타임스탬프 미차단 + 49.7일 랩
- [ ] (D) nonce 셋 무한 증식(창과 미결합) → 메모리 DoS
- [ ] MAC 검증 부재 → nonce/시간/payload 전면 위조 가능
- [ ] seenNonces/history 멀티스레드 race + check-then-insert TOCTOU
- [ ] Accept 길이 검증/엔디안 명세 부재