44. UDP 연결 마이그레이션의 경로 검증 부재 (세션 탈취·오배달, C#)
난이도 중해설 — UDP 연결 마이그레이션의 경로 검증 부재 (세션 탈취·오배달, C#)
난이도: 중상
요약
OnPacket 은 connectionId 로 세션을 찾은 뒤, 패킷의 소스 주소가 세션의 현재 remote 와 다르면 즉시 세션의 원격 주소를 그 소스로 갱신한다. 새 경로가 정당한지 검증(챌린지-리스폰스)하지 않으므로, 공격자가 세션의 트래픽을 자신에게 끌어오거나(탈취) 임의의 희생자 주소로 향하게(오배달/증폭) 만들 수 있다.
문제점
분류 태그: 경로 검증 부재 / 인증 이전 상태 변경 (연결 마이그레이션 취약점)
결함 (A) — connectionId 만으로 remote 즉시 갱신
UDP 소스 주소는 위조 가능하고, connectionId 는 "이 세션을 지목한다"는 라우팅 힌트일 뿐 송신자가 그 경로의 정당한 주인임을 증명하지 못한다.
- 세션 탈취: 공격자가 어떤 경로로 유효 connectionId 가 담긴 패킷을 보내면(예: 재전송/추측/사이드채널) 서버가 remote 를 공격자 주소로 바꿔, 이후 게임 상태·채팅·민감 응답이 공격자에게 배달된다.
- 오배달/증폭 DoS: 공격자가 소스 주소를 희생자 IP로 위조해 보내면, 서버가 세션 트래픽을 희생자에게 쏟아붓는다(반사 증폭).
결함 (B) — 갱신된 주소를 신뢰해 처리·송신
AEAD 검증(AeadOpen)은 페이로드의 무결성·기밀성은 지키지만 소스 주소의 정당성과는 무관하다. 인증이 통과해도 그것은 "이 connectionId 의 키로 만든 정상 패킷"임을 말할 뿐, "이 새 주소로 보내도 안전"함을 뜻하지 않는다(정상 패킷을 캡처해 다른 주소에서 재주입 가능). 그런데 (A)에서 이미 remote 를 바꿨으므로, 이후 Dispatch 로 생성되는 응답이 검증 안 된 경로로 나간다.
세션이 정당하게 이동(모바일 전환)하는 경우와 공격이 코드상 구분되지 않는 것이 핵심 결함이다.
수정안
QUIC 식 경로 검증(path validation): 새 주소가 관측되면 즉시 마이그레이션하지 말고, 그 주소로 예측 불가한 챌린지 토큰을 보내고 동일 토큰이 그 주소에서 되돌아올 때만 마이그레이션한다. 검증 전에는 기존 경로로 계속 송신한다.
public void OnPacket(IPEndPoint source, long connId, byte[] frame)
{
if (!_byConn.TryGetValue(connId, out var s)) return;
byte[] plaintext = AeadOpen(s.Key, frame); // 먼저 인증
if (plaintext == null) return; // 위조/변조 패킷은 여기서 탈락
// 경로 검증 응답이면 처리하고, 성공 시에만 remote 교체
if (IsPathChallengeResponse(plaintext, out var token))
{
if (s.PendingMigration != null &&
s.PendingMigration.Address.Equals(source) &&
ConstantTimeEquals(token, s.PendingMigration.Token))
{
s.Remote = source; // 검증된 경로로만 마이그레이션
s.PendingMigration = null;
}
return;
}
if (!s.Remote.Equals(source))
{
// 새 주소 관측: 즉시 바꾸지 말고 챌린지 시작(레이트 리밋 필요)
if (s.PendingMigration == null || !s.PendingMigration.Address.Equals(source))
{
var tok = RandomToken();
s.PendingMigration = new Migration(source, tok);
s.SendTo(source, MakePathChallenge(s.Key, tok)); // 새 경로로 챌린지
}
// 검증 전까지 이 패킷의 로직은 처리하되, 응답은 기존 Remote 로만
}
s.Dispatch(plaintext); // 응답 송신은 항상 검증된 s.Remote 로
}
원칙: (1) 인증을 먼저, (2) 주소 변경은 챌린지-리스폰스로만, (3) 검증 전 송신은 기존 경로로, (4) 챌린지에 레이트 리밋(증폭 방지).
더 나은 설계
- anti-amplification 한도: 미검증 경로로는 받은 바이트의 N배 이하만 보내도록 제한(QUIC 3배 규칙).
- 동시 마이그레이션 억제: 여러 새 주소가 번갈아 오면 각기 챌린지하되, 검증 성공 1건만 채택.
- connectionId 회전: 마이그레이션 시 새 connectionId 를 발급해 경로 상관(linkability)·추적을 줄인다.
- 트레이드오프: 경로 검증은 1 RTT 지연을 추가하지만 모바일 전환 빈도가 낮아 체감 영향은 작다. 반면 검증 생략의 대가는 세션 탈취/증폭이라 타협 불가.
면접 포인트
- AEAD 인증 통과와 "이 주소로 보내도 안전"이 왜 별개인가(캡처-재주입).
- QUIC 경로 검증(챌린지-리스폰스)과 anti-amplification 3배 규칙의 목적.
- 정당한 NAT 리바인딩과 스푸핑 공격을 코드가 어떻게 구분해야 하는가.
해설 — UDP 연결 마이그레이션: 주소 비교 오류 + 경로 검증 부재 (C++)
난이도: 중상
요약
두 결함이 겹친다. (A) sockaddr_storage 전체를 memcmp 로 비교해 "주소가 바뀌었는지"를 판단하는데, 이 구조체에는 비교에 무관한 패딩·미사용 바이트가 있어 같은 주소도 매번 다르다고 오판한다. 그 결과 (B) connectionId 만 보고 경로 검증 없이 remote 를 계속 갱신하게 되어, 세션 탈취·오배달(증폭) 취약점이 상시화된다.
문제점
분류 태그: 구조체 전체 memcmp(패딩 포함) 비교 오류 / 경로 검증 부재
결함 (A) — memcmp(sizeof(sockaddr_storage)) 로 주소 동등성 판정
sockaddr_storage 는 128바이트로, 실제 의미 있는 필드는 주소 패밀리에 따라 앞부분(sockaddr_in 은 16바이트: family·port·addr, 나머지 8바이트 sin_zero 는 미사용)뿐이다. sin_zero·꼬리 패딩은 커널/복사 과정에서 임의 값일 수 있어, 논리적으로 같은 주소라도 memcmp 결과가 매번 달라질 수 있다.
- 증상: 정당한 단일 클라이언트인데도 매 패킷마다
!= 0이 되어 remote 를 계속 재대입(무해해 보이지만 (B)와 결합하면 검증 없는 갱신을 상시 트리거). - 더 나쁜 경우: 실제로 다른 주소인데 우연히 관련 없는 바이트만 달라, 진짜 마이그레이션 판단 로직이 오작동.
- 올바른 비교는 패밀리별로 의미 있는 필드만: IPv4 는
sin_family·sin_port·sin_addr, IPv6 는sin6_family·sin6_port·sin6_addr(필요 시sin6_scope_id).
결함 (B) — 경로 검증 없는 remote 갱신
C# 트윈과 동일한 근본 결함: connectionId 는 라우팅 힌트일 뿐 경로 소유권 증명이 아니다. 위조 소스 주소로 유효 connectionId 패킷을 보내면 서버가 remote 를 그리로 바꿔 세션 탈취 또는 희생자 IP로의 반사 증폭이 가능하다. AEAD 인증 통과는 페이로드 정당성일 뿐, 캡처한 정상 패킷을 다른 주소에서 재주입하는 것을 막지 못한다.
수정안
주소 비교를 패밀리별 필드로 교정하고, QUIC 식 경로 검증(챌린지-리스폰스)을 넣는다.
static bool sameEndpoint(const sockaddr_storage& a, const sockaddr_storage& b) {
if (a.ss_family != b.ss_family) return false;
if (a.ss_family == AF_INET) {
auto* x = reinterpret_cast<const sockaddr_in*>(&a);
auto* y = reinterpret_cast<const sockaddr_in*>(&b);
return x->sin_port == y->sin_port &&
x->sin_addr.s_addr == y->sin_addr.s_addr; // sin_zero 무시
}
if (a.ss_family == AF_INET6) {
auto* x = reinterpret_cast<const sockaddr_in6*>(&a);
auto* y = reinterpret_cast<const sockaddr_in6*>(&b);
return x->sin6_port == y->sin6_port &&
std::memcmp(&x->sin6_addr, &y->sin6_addr, sizeof(in6_addr)) == 0;
}
return false;
}
void onPacket(const sockaddr_storage& source, std::int64_t connId,
const std::uint8_t* frame, std::size_t len)
{
auto it = byConn_.find(connId);
if (it == byConn_.end()) return;
Session* s = it->second;
std::vector<std::uint8_t> pt;
if (!aeadOpen(s->key, frame, len, pt)) return; // 인증 먼저
if (isPathResponse(pt) && s->pending &&
sameEndpoint(s->pendingAddr, source) &&
constantTimeEq(pathToken(pt), s->pendingToken)) {
s->remote = source; // 검증된 경로로만 교체
s->pending = false;
return;
}
if (!sameEndpoint(s->remote, source)) { // 필드 기준 비교
if (!s->pending || !sameEndpoint(s->pendingAddr, source)) {
s->pendingAddr = source; s->pending = true;
s->pendingToken = randomToken();
s->sendTo(source, makePathChallenge(s->key, s->pendingToken)); // 챌린지
}
// 검증 전: 응답은 기존 remote 로만
}
s->dispatch(pt);
}
더 나은 설계
- anti-amplification: 미검증 경로로는 수신 바이트의 3배 이하만 송신(반사 증폭 차단).
- connectionId 회전 + 커널 비의존 비교 유틸: 주소 비교/해시를 한 곳에 캡슐화해
memcmp(sockaddr_storage)실수를 원천 차단.unordered_map키로 쓸 때도 동일 규칙. - 경로별 상태: in-flight 검증을 주소별로 관리하고 레이트 리밋.
- 트레이드오프: 경로 검증 1 RTT 추가 vs 탈취/증폭 방지. 후자는 타협 불가.
면접 포인트
sockaddr_storage/sockaddr_in을memcmp로 비교하면 왜 위험한가(sin_zero·패딩).- connectionId 라우팅과 경로 소유권 증명이 왜 별개이며, QUIC 경로 검증이 무엇을 보장하는가.
- AEAD 인증 통과가 소스 주소 정당성을 보증하지 못하는 이유.