8. C# 재접속 토큰 검증 / 세션 탈취 방지
난이도 상내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — C# 재접속 토큰 검증 / 세션 탈취 방지
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
재접속 토큰이 추측 가능하고, 검증/소비/동시성이 모두 허술해 세션 탈취가 가능하다. 핵심 결함:
- 예측 가능한 토큰(
"R" + accountId) — 남의 accountId 만 알면 토큰을 만들어 탈취. - 토큰 비교가 평문 사전 조회(
TryGetValue) — 타이밍 사이드채널 + 일회성 미보장 (성공해도 토큰이 무효화 안 됨 → 재사용/리플레이). - 동시 재접속 race — 검증 후
Rebind가 락 밖이라 두 클라가 같은 세션을 동시에 이어받을 수 있다(중복 바인딩/탈취). - 만료 정리가 lazy 만 존재 — 접근이 없으면 보관 세션이 영원히 남음(누수).
- 세션 식별과 인증의 결합 — 토큰 하나가 "식별 + 인증"을 겸해, 노출되면 즉시 탈취.
문제점
(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/계정을 토큰버킷으로 제한. 열거 공격을 조기 차단.
면접 포인트
- "재접속 토큰을 accountId 기반으로 만들면 무엇이 문제인가?" → 예측·열거 가능해 세션 탈취로 직결. 토큰은 식별자가 아니라 암호 난수 비밀 (128bit+)이어야 하고 계정 정보에서 유도되면 안 된다.
- "같은 토큰으로 두 클라가 동시에 재접속하면? 어떻게 하나만 성공시키나?"
→ "찾기→검증→소유권 이전"을 원자화.
ConcurrentDictionary.TryRemove로 보관소에서 원자적으로 빼며 가져오면 단 하나만 성공하고, 일회성·리플레이 방지도 함께 달성. - "토큰 비교에 일반 문자열
==를 쓰면? 토큰 회전은 왜 필요한가?" → 평문 비교는 타이밍 사이드채널 위험 →FixedTimeEquals. 회전(rotation)은 재접속 성공 시 토큰을 새로 발급·옛 토큰 폐기해 노출/리플레이 창을 닫기 위함.
해설 · C++
해설 — C++ 재접속 토큰 검증 / 세션 탈취 방지
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
재접속 토큰이 추측 가능하고, 검증/소비/동시성이 모두 허술해 세션 탈취가 가능하다. 핵심 결함:
- 예측 가능한 토큰(
"R" + accountId) — 남의 accountId 만 알면 토큰을 만들어 탈취. - 토큰 조회 + 소비 비원자 — 성공해도 토큰이 무효화 안 됨(
erase없음) → 재사용·리플레이. - 동시 재접속 race — 검증 후
Rebind가 락 밖이라 두 클라가 같은 세션을 동시에 이어받을 수 있다(중복 바인딩/탈취). - 만료 정리가 lazy 만 존재 — 접근이 없으면 보관 세션이 영원히 남음(누수).
- raw 포인터 + 평문 비교 — 검증과 소유권 이전이 분리돼 use-after / dangling 위험.
문제점
(B) 예측 가능한 토큰 → 직접적 세션 탈취 (분류: 보안, 치명)
- 증상: 토큰이
"R" + accountId. accountId 는 친구목록·랭킹·관전 등으로 노출되거나 순차값이라 열거 가능. 공격자가R12345를 보내면 그 계정의 보관 세션을 가로챈다. - 재현조건: 피해자가 잠깐 끊긴 ResumeWindow 동안 공격자가 토큰을 추측해 재접속.
- 근본원인: 토큰이 비밀(secret)이 아니라 식별자다. 재접속 토큰은 충분한
엔트로피(128bit+)의 암호학적 난수여야 하고, 계정 정보에서 유도되면 안 된다.
(
rand()/std::to_string같은 예측 가능한 생성도 금지.)
(C) 일회성·무효화 부재 → 리플레이 (분류: 보안)
- 증상:
TryResume성공 후에도 토큰이 보관소에 남고 무효화되지 않는다(성공 시erase가 없다 —Rebind만 한다). 같은 토큰을 다시 쓰면 또 성공할 수 있다. - 근본원인: 토큰이 일회성(single-use) 으로 설계되지 않음. 재접속마다 새 토큰을 발급(rotation)하고 옛 토큰을 즉시 폐기해야 한다.
(C)/(E) 검증과 Rebind 사이의 동시성 race (분류: 동시성/보안)
- 증상: 락 안에서
find+ 만료검사까지만 하고, 락을 푼 뒤Rebind한다. 두 재접속 스레드가 같은 token 으로 거의 동시에 들어오면 둘 다 검증을 통과하고 둘 다Rebind→ 같은 세션에 소켓 2개가 바인딩되거나, 정상 유저와 공격자가 동시에 세션을 잡는다(탈취 성공).suspended_에서 원자적으로 빼면서 가져와야 한다. - 근본원인: "찾기 → 검증 → 소유권 이전"이 원자적이지 않음(락 범위가 짧음).
(D) 만료 정리가 lazy 전용 (분류: 수명관리/메모리)
- 증상: 만료는
TryResume가 호출될 때만 검사된다. 그 토큰으로 아무도 재접속하지 않으면 만료된 세션이 영원히suspended_에 남아(게다가 raw 포인터라 누가 delete 하는지도 불명) 메모리/계정 슬롯을 점유. - 근본원인: 능동적(timer 기반) 스윕 부재 + 소유권 불명.
(A)/(E) IP/디바이스 바인딩·이전 연결 종료 누락 + raw 포인터 (분류: 보안/수명관리)
- 증상:
s->clientIp = fromIp로 검증 없이 덮어쓴다. 원래 클라의 IP/디바이스와 대조하지 않으니 탈취 시 흔적도 없다. 원래 소켓이 half-open 으로 살아있어도 명시적으로 끊지 않아 한 세션에 두 연결이 공존. rawPlayerSession*를 락 밖에서 만지므로, 만료 정리(erase)와 겹치면 use-after-free 위험도 있다. - 근본원인: 재접속 시 동일 주체 검증·기존 연결 정리 정책 부재 + 명확한 소유권 부재.
수정안
핵심: 암호 난수 토큰 + 일회성 회전 + 원자적 소비(찾기+제거) + 능동 만료 + 상수시간 비교 + shared_ptr
#include <unordered_map>
#include <mutex>
#include <memory>
#include <string>
#include <chrono>
#include <random>
#include <array>
#include <cstdint>
struct Socket { int fd; };
class PlayerSession {
public:
int64_t accountId;
std::string resumeToken;
std::string clientIp;
std::chrono::steady_clock::time_point disconnectedAt;
void Rebind(Socket*) { /* 새 소켓 바인딩 */ }
void CloseStaleConnection() { /* half-open 옛 소켓 강제 종료(멱등) */ }
};
class ResumeService {
std::unordered_map<std::string, std::shared_ptr<PlayerSession>> suspended_;
std::mutex mtx_;
std::chrono::seconds resumeWindow_{60};
// 128bit+ 암호 난수 토큰. 계정 정보와 무관. (실서비스는 OS CSPRNG 사용)
static std::string GenerateToken() {
std::random_device rd; // 데모용. 실무는 RAND_bytes/getrandom 등 CSPRNG
std::array<uint64_t, 4> buf{}; // 256bit
for (auto& x : buf) x = (uint64_t(rd()) << 32) ^ rd();
std::string out;
for (auto x : buf) out += std::to_string(x); // 실무는 base64url 인코딩
return out;
}
// 상수시간 비교(타이밍 사이드채널 방지)
static bool FixedTimeEquals(const std::string& a, const std::string& b) {
if (a.size() != b.size()) return false;
unsigned char diff = 0;
for (size_t i = 0; i < a.size(); ++i) diff |= (a[i] ^ b[i]);
return diff == 0;
}
public:
std::string IssueToken(const std::shared_ptr<PlayerSession>& s) {
std::string token = GenerateToken();
s->resumeToken = token;
return token;
}
void Suspend(const std::shared_ptr<PlayerSession>& s) {
s->disconnectedAt = std::chrono::steady_clock::now();
std::lock_guard<std::mutex> lk(mtx_);
suspended_[s->resumeToken] = s;
}
std::shared_ptr<PlayerSession> TryResume(const std::string& token, Socket* newSocket,
const std::string& fromIp, int64_t claimedAccountId) {
std::shared_ptr<PlayerSession> s;
{
std::lock_guard<std::mutex> lk(mtx_);
auto it = suspended_.find(token);
if (it == suspended_.end()) return nullptr;
// (1) 원자적 소비: 찾으면서 동시에 제거 → 두 스레드 중 하나만 성공, 일회성 보장
s = it->second;
suspended_.erase(it);
}
// (2) 만료 검사 (이미 제거했으므로 만료면 그냥 버림)
auto now = std::chrono::steady_clock::now();
if (now - s->disconnectedAt > resumeWindow_) return nullptr;
// (3) 주체 검증: 클라가 주장하는 accountId 와 일치 + 상수시간 토큰 재확인(다층 방어)
if (s->accountId != claimedAccountId || !FixedTimeEquals(s->resumeToken, token))
return nullptr; // 의심 시도 — 보안 로그/알람. 세션은 이미 소비됨(안전).
// (4) 토큰 회전: 새 토큰 발급(옛 토큰은 이미 제거 → 리플레이 차단)
IssueToken(s);
// (5) 기존 연결 잔재 정리 후 새 소켓 바인딩
s->CloseStaleConnection();
s->clientIp = fromIp;
s->Rebind(newSocket);
return s;
}
// (6) 능동 만료 스윕: 타이머 스레드가 주기적으로 호출
void SweepExpired() {
auto now = std::chrono::steady_clock::now();
std::lock_guard<std::mutex> lk(mtx_);
for (auto it = suspended_.begin(); it != suspended_.end(); ) {
if (now - it->second->disconnectedAt > resumeWindow_) {
it->second->CloseStaleConnection();
it = suspended_.erase(it); // shared_ptr 해제 → 안전 소멸
} else ++it;
}
}
};
포인트
- 암호 난수 토큰: 추측·열거 불가. 계정 정보에서 유도 금지.
rand()대신 CSPRNG (getrandom/RAND_bytes)를 써야 한다(여기 데모는 의도 표현용). find+erase를 같은 락 안에서 원자적 소비: "찾기+소유권 이전"을 한 번에 → 동시 재접속 race 에서 단 하나만 성공. 일회성도 자동 보장(소비되면 사라짐).- 토큰 회전: 재접속마다 새 토큰 → 옛 토큰 리플레이 차단.
- 상수시간 비교(
FixedTimeEquals): 토큰을 수동 비교할 때 타이밍 사이드채널 방지. - 능동 스윕 + 기존 연결 정리(멱등) 로 좀비/이중 바인딩 제거.
shared_ptr로 소유권 명확화 → 만료 정리와 재접속이 겹쳐도 UAF 없음.
더 나은 설계
1) 토큰을 "식별자 + 비밀"로 분리
- 클라가
{sessionId, secret}을 보내게 한다. sessionId 로 O(1) 조회하고, secret 은 서버가 저장한 해시와 상수시간 비교. 토큰 자체를 맵 키로 쓰면 토큰 노출이 곧 조회 가능으로 이어지므로, 비밀은 해시로만 보관. - 트레이드오프: 패킷에 필드 하나 추가, 해시 검증 비용. 대신 탈취 내성 ↑.
2) 서명 기반 stateless 토큰 (HMAC 류)
HMAC(serverKey, accountId|sessionId|expiry)를 토큰에 실어 서버가 상태 없이 검증. 보관소 부담이 준다. 단 무효화(로그아웃/회전) 가 어려워 짧은 만료 + 블랙리스트 병행.- 트레이드오프: 폐기 어려움 vs 수평 확장 용이(서버 간 공유 키만 있으면 어디서든 검증).
3) 디바이스/세션 바인딩 강화
- 토큰을 디바이스 핑거프린트·이전 IP 대역과 soft-bind. 급격한 변화 시 추가 인증 요구(step-up). IP 완전 일치는 모바일(IP 변동)에선 과해 false reject 위험 → 대역/ASN 수준으로 완화.
4) 레이트 리밋·이상탐지
- 토큰 재접속 실패가 반복되는 IP/계정을 토큰버킷으로 제한. 열거 공격을 조기 차단.
면접 포인트
- "재접속 토큰을 accountId 기반으로 만들면 무엇이 문제인가?" → 예측·열거 가능해 세션 탈취로 직결. 토큰은 식별자가 아니라 암호 난수 비밀 (128bit+, CSPRNG)이어야 하고 계정 정보에서 유도되면 안 된다.
- "같은 토큰으로 두 클라가 동시에 재접속하면? 어떻게 하나만 성공시키나?"
→ "찾기→검증→소유권 이전"을 원자화. 보관소에서
find+erase를 같은 락 안에서 수행해 원자적으로 빼며 가져오면 단 하나만 성공하고, 일회성·리플레이 방지도 함께 달성. - "토큰 비교에 평문
==를 쓰면? 토큰 회전은 왜 필요한가?" → 평문 비교는 타이밍 사이드채널 위험 → 상수시간 비교(FixedTimeEquals/CRYPTO_memcmp). 회전(rotation)은 재접속 성공 시 토큰을 새로 발급·옛 토큰 폐기해 노출/리플레이 창을 닫기 위함.