← 문제로

8. C# 재접속 토큰 검증 / 세션 탈취 방지

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

해설 — C# 재접속 토큰 검증 / 세션 탈취 방지

난이도: 상

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

요약

재접속 토큰이 추측 가능하고, 검증/소비/동시성이 모두 허술해 세션 탈취가 가능하다. 핵심 결함:

  1. 예측 가능한 토큰("R" + accountId) — 남의 accountId 만 알면 토큰을 만들어 탈취.
  2. 토큰 비교가 평문 사전 조회(TryGetValue) — 타이밍 사이드채널 + 일회성 미보장 (성공해도 토큰이 무효화 안 됨 → 재사용/리플레이).
  3. 동시 재접속 race — 검증 후 Rebind 가 락 밖이라 두 클라가 같은 세션을 동시에 이어받을 수 있다(중복 바인딩/탈취).
  4. 만료 정리가 lazy 만 존재 — 접근이 없으면 보관 세션이 영원히 남음(누수).
  5. 세션 식별과 인증의 결합 — 토큰 하나가 "식별 + 인증"을 겸해, 노출되면 즉시 탈취.

문제점

(B) 예측 가능한 토큰 → 직접적 세션 탈취 (분류: 보안, 치명)

  • 증상: 토큰이 "R" + accountId. accountId 는 친구목록·랭킹·관전 등으로 노출되거나 순차값이라 열거 가능. 공격자가 R12345 를 보내면 그 계정의 보관 세션을 가로챈다.
  • 재현조건: 피해자가 잠깐 끊긴 ResumeWindow 동안 공격자가 토큰을 추측해 재접속.
  • 근본원인: 토큰이 비밀(secret)이 아니라 식별자다. 재접속 토큰은 충분한 엔트로피(128bit+)의 암호학적 난수여야 하고, 계정 정보에서 유도되면 안 된다.

(B)/(C) 일회성·무효화 부재 → 리플레이 (분류: 보안)

  • 증상: TryResume 성공 후에도 토큰이 보관소에 남고 무효화되지 않는다(성공 시 Remove 가 없다 — Rebind 만 한다). 같은 토큰을 다시 쓰면 또 성공할 수 있다.
  • 근본원인: 토큰이 일회성(single-use) 으로 설계되지 않음. 재접속마다 새 토큰을 발급(rotation)하고 옛 토큰을 즉시 폐기해야 한다.

(C)/(E) 검증과 Rebind 사이의 동시성 race (분류: 동시성/보안)

  • 증상: 락 안에서 TryGetValue + 만료검사까지만 하고, 락을 푼 뒤 Rebind 한다. 두 재접속 스레드가 같은 token 으로 거의 동시에 들어오면 둘 다 검증을 통과하고 둘 다 Rebind → 같은 세션에 소켓 2개가 바인딩되거나, 정상 유저와 공격자가 동시에 세션을 잡는다(탈취 성공). _suspended 에서 원자적으로 빼면서 가져와야 한다.
  • 근본원인: "찾기 → 검증 → 소유권 이전"이 원자적이지 않음.

(D) 만료 정리가 lazy 전용 (분류: 수명관리/메모리)

  • 증상: 만료는 TryResume 가 호출될 때만 검사된다. 그 토큰으로 아무도 재접속하지 않으면 만료된 세션이 영원히 _suspended 에 남아 메모리/계정 슬롯을 점유.
  • 근본원인: 능동적(timer 기반) 스윕 부재.

(E) IP/디바이스 바인딩·이전 연결 종료 누락 (분류: 보안/정합성)

  • 증상: s.ClientIp = fromIp검증 없이 덮어쓴다. 원래 클라의 IP/디바이스와 대조하지 않으니 탈취 시 흔적도 없다. 또 원래 소켓이 아직 half-open 으로 살아있으면 명시적으로 끊지 않아 한 세션에 두 연결이 공존할 수 있다.
  • 근본원인: 재접속 시 동일 주체 검증·기존 연결 정리 정책 부재.

수정안

핵심: 암호 난수 토큰 + 일회성 회전 + 원자적 소비 + 능동 만료 + 상수시간 비교

using System.Security.Cryptography;
using System.Collections.Concurrent;

public sealed class ResumeService
{
    private readonly ConcurrentDictionary<string, PlayerSession> _suspended = new();
    private static readonly TimeSpan ResumeWindow = TimeSpan.FromSeconds(60);

    // 128bit+ 암호 난수 → URL-safe base64. 계정 정보와 무관.
    public string IssueToken(PlayerSession s)
    {
        Span<byte> buf = stackalloc byte[32];           // 256bit 엔트로피
        RandomNumberGenerator.Fill(buf);
        string token = Convert.ToBase64String(buf);
        s.ResumeToken = token;
        return token;
    }

    public void Suspend(PlayerSession s)
    {
        s.Connected = false;
        s.DisconnectedAt = DateTime.UtcNow;
        _suspended[s.ResumeToken] = s;                  // 토큰을 키로 보관
    }

    public PlayerSession TryResume(string token, Socket newSocket,
                                   IPAddress fromIp, long claimedAccountId)
    {
        if (string.IsNullOrEmpty(token)) return null;

        // (1) 원자적 소비: 찾으면서 동시에 제거 → 두 스레드 중 하나만 성공
        if (!_suspended.TryRemove(token, out var s))
            return null;

        // (2) 만료 검사 (제거 후이므로 만료면 그냥 버림)
        if (DateTime.UtcNow - s.DisconnectedAt > ResumeWindow)
            return null;

        // (3) 주체 검증: 클라가 주장하는 accountId 와 보관 세션이 일치하는가
        //     (토큰 자체가 비밀이지만, 다층 방어로 계정 바인딩 확인)
        if (s.AccountId != claimedAccountId)
        {
            // 의심스러운 시도 — 보안 로그/알람. 세션은 이미 소비됐으므로 안전.
            return null;
        }

        // (4) 토큰 회전: 새 토큰 발급, 옛 토큰은 이미 제거됨(리플레이 차단)
        IssueToken(s);

        // (5) 기존 연결 잔재 정리 후 새 소켓 바인딩
        s.CloseStaleConnection();   // half-open 옛 소켓 강제 종료(멱등)
        s.ClientIp = fromIp;
        s.Rebind(newSocket);
        return s;
    }

    // (6) 능동 만료 스윕: 타이머 스레드가 주기적으로 호출
    public void SweepExpired()
    {
        var now = DateTime.UtcNow;
        foreach (var kv in _suspended)
            if (now - kv.Value.DisconnectedAt > ResumeWindow)
                if (_suspended.TryRemove(kv.Key, out var dead))
                    dead.CloseStaleConnection();
    }
}

포인트

  • 암호 난수 토큰: 추측·열거 불가. 계정 정보에서 유도 금지.
  • TryRemove 로 원자적 소비: "찾기+소유권 이전"을 한 번에 → 동시 재접속 race 에서 단 하나만 성공. 일회성도 자동 보장(소비되면 사라짐).
  • 토큰 회전: 재접속할 때마다 새 토큰 → 옛 토큰 리플레이 차단.
  • 상수시간 비교: 사전 조회(TryGetValue)는 키 해시 기반이라 직접적 타이밍 노출은 작지만, 만약 토큰을 수동 문자열 비교한다면 CryptographicOperations.FixedTimeEquals 를 써 타이밍 사이드채널을 막아야 한다.
  • 능동 스윕 + 기존 연결 정리(멱등) 로 좀비/이중 바인딩 제거.

더 나은 설계

1) 토큰을 "식별자 + 비밀"로 분리

  • 클라가 {sessionId, secret} 을 보내게 한다. sessionId 로 O(1) 조회하고, secret 은 서버가 저장한 해시와 상수시간 비교. 토큰 자체를 맵 키로 쓰면 토큰 노출이 곧 조회 가능으로 이어지므로, 비밀은 해시로만 보관.
  • 트레이드오프: 패킷에 필드 하나 추가, 해시 검증 비용. 대신 탈취 내성 ↑.

2) 서명 기반 stateless 토큰 (HMAC/JWT 류)

  • HMAC(serverKey, accountId|sessionId|expiry) 를 토큰에 실어 서버가 상태 없이 검증. 보관소 부담이 준다. 단 무효화(로그아웃/회전) 가 어려워 짧은 만료 + 블랙리스트 병행.
  • 트레이드오프: 폐기 어려움 vs 수평 확장 용이(서버 간 공유 키만 있으면 어디서든 검증).

3) 디바이스/세션 바인딩 강화

  • 토큰을 디바이스 핑거프린트·이전 IP 대역과 soft-bind. 급격한 변화 시 추가 인증 요구(step-up). IP 완전 일치는 모바일(IP 변동)에선 과해 false reject 위험 → 대역/ASN 수준으로 완화.

4) 레이트 리밋·이상탐지

  • 토큰 재접속 실패가 반복되는 IP/계정을 토큰버킷으로 제한. 열거 공격을 조기 차단.

면접 포인트

  1. "재접속 토큰을 accountId 기반으로 만들면 무엇이 문제인가?" → 예측·열거 가능해 세션 탈취로 직결. 토큰은 식별자가 아니라 암호 난수 비밀 (128bit+)이어야 하고 계정 정보에서 유도되면 안 된다.
  2. "같은 토큰으로 두 클라가 동시에 재접속하면? 어떻게 하나만 성공시키나?" → "찾기→검증→소유권 이전"을 원자화. ConcurrentDictionary.TryRemove 로 보관소에서 원자적으로 빼며 가져오면 단 하나만 성공하고, 일회성·리플레이 방지도 함께 달성.
  3. "토큰 비교에 일반 문자열 == 를 쓰면? 토큰 회전은 왜 필요한가?" → 평문 비교는 타이밍 사이드채널 위험 → FixedTimeEquals. 회전(rotation)은 재접속 성공 시 토큰을 새로 발급·옛 토큰 폐기해 노출/리플레이 창을 닫기 위함.