37. 재접속 재바인딩 시 이전 연결 미정리로 인한 유령 연결 (C#)
난이도 상해설 — 재접속 재바인딩 시 이전 연결 미정리로 인한 유령 연결 (C#)
난이도: 상
요약
Reconnect는 세션에 새 연결을 매달기만 하고(A) 이전 연결을 닫거나 분리하지
않는다. 이전 연결의 수신 루프는 여전히 살아 있어 BoundSession이 같은 세션을
가리키므로(B), 두 물리 연결이 하나의 세션에 동시에 패킷을 흘려보낸다. "세션당
활성 연결 1개" 불변식이 깨져 중복 처리·상태 오염·인증 우회가 발생한다.
문제점
- 분류: 상태 전이 불변식 위반(이중 바인딩) + 리소스/수명 관리 누락 + ABA류 경합.
- 증상:
- 이중 입력: 옛 연결과 새 연결이 동시에
session.Handle을 호출 → 같은 명령이 두 번 적용(이중 공격/이중 이동), 또는 서로 다른 두 소스의 명령이 인터리브되어 세션 상태가 뒤엉킴. - 유령 연결 누수: 옛
Connection이Close되지 않아 소켓·수신 스레드·버퍼가 계속 살아 있다(리소스 누수). 유예 만료로 세션이 정리돼도 옛 연결은 남는다. - 응답 오배송:
session.Conn은 새 연결을 가리키지만 옛 연결도 여전히 세션을 참조하므로, 세션이 내려보내는 응답과 옛 연결이 올려보내는 요청의 대상이 어긋난다. - 하이재킹 위험: 옛 연결(예: 탈취된 소켓)이 유효 토큰 없이도 계속 세션을 조작할 수 있다.
- 이중 입력: 옛 연결과 새 연결이 동시에
- 재현 조건: 옛 연결의 수신 루프가 아직 안 끝난 시점에 재접속이 들어옴(정확히
모바일 재접속의 전형적 타이밍). 또한 두 재접속이 거의 동시에 오면
_byToken자체도 무보호라 사전 접근 레이스. - 근본 원인: 재바인딩을 "포인터 교체"로만 처리하고, (a) 이전 연결의 세션 참조 해제, (b) 이전 연결 닫기, (c) 교체 구간의 원자적 배제, (d) 토큰 재발급/일회성 을 모두 빠뜨림.
수정안
public bool Reconnect(string token, Connection newConn)
{
Connection old = null;
lock (_gate) // 교체 구간 원자화
{
if (!_byToken.TryGetValue(token, out var session))
return false;
old = session.Conn; // 이전 연결 회수
if (old != null) old.BoundSession = null; // (1) 이전 연결의 세션 참조 끊기
session.Conn = newConn; // (2) 새 연결로 교체
newConn.BoundSession = session;
_byToken[token] = RotateToken(session); // (3) 토큰 일회성: 재발급
}
old?.Close(); // (4) 락 밖에서 이전 연결 닫기
return true;
}
그리고 OnPacket은 끊긴 참조를 확인해 유령 입력을 막는다:
public void OnPacket(byte[] data)
{
var s = BoundSession; // 지역 스냅샷
if (s == null || s.Conn != this) return; // 나는 더 이상 이 세션의 활성 연결이 아니다
s.Handle(data);
}
핵심: ① 이전 연결의 BoundSession을 끊고 닫아 유령 입력·누수 제거,
② 교체를 락으로 원자화, ③ Conn != this 재확인으로 경합 잔여 패킷 차단,
④ 토큰 회전(one-time) 으로 옛 토큰 재사용 하이재킹 방지.
더 나은 설계
- 세대(generation) 토큰: 각 바인딩에 epoch를 부여해 옛 연결이 올린 패킷은
epoch 불일치로 O(1) 폐기.
Conn != this체크의 일반화. - 명시적 상태 머신:
Session을Active / Suspended / Rebinding / Closed로 두고, 전이 중(Rebinding)에는 양쪽 연결의 입력을 모두 큐잉/차단해 인터리빙을 원천 봉쇄. - 드레이닝: 이전 연결을 즉시
Close하기 전에 in-flight 송신을 짧게 flush하고 RST 대신 graceful하게 종료(별도 half-close 문제와 연계). 트레이드오프: 즉시성 대 마지막 응답 보존. - 유예 타이머 취소: 재바인딩 성공 시 세션 파기 유예 타이머를 반드시 취소(안 하면 재접속 직후 만료로 세션이 사라지는 별도 결함).
면접 포인트
- "재접속을 포인터 교체로만 처리하면?" → 옛 연결이 세션을 계속 참조/조작하는 이중 바인딩. 교체는 항상 이전 소유권 회수(끊기+닫기) 와 쌍이어야 한다.
- "재접속 토큰을 재사용 가능하게 두면?" → 탈취·재생 공격. 성공 시 즉시 회전시켜 일회성으로 만든다.
해설 — 재접속 재바인딩의 이중 바인딩과 이전 연결 UAF (C++)
난이도: 상
요약
reconnect는 세션에 새 연결을 매달기만 하고(A) 이전 연결의 bound를 끊거나
연결을 닫지 않는다. 이전 연결은 여전히 bound == session이라 그 수신 스레드가
계속 session->handle을 호출(B)해 두 연결이 한 세션에 동시에 입력한다. 게다가
closeSocket이 이전 연결의 unique_ptr를 파괴하면, 아직 도는 이전 연결의 수신
스레드가 소멸된 Connection/Session 참조를 만져 use-after-free로 이어진다.
문제점
- 분류: 상태 불변식 위반(이중 바인딩) + use-after-free + 데이터 레이스.
- 증상:
- 이중 입력: 옛 연결과 새 연결이 동시에
session->handle호출 → 명령 이중 적용/상태 오염. "세션당 활성 연결 1" 불변식 붕괴. - UAF(연결): 재접속 후 옛 소켓이 EOF로
closeSocket되어Connection이 파괴되는데, 그 연결의 수신 스레드는 여전히onPacket에서this/bound를 역참조 중 → heap-use-after-free(ASan 검출). 소멸과 콜백이 동기화 없이 경합. - UAF(세션): 유예 만료로
Session이 파괴돼도 옛 연결의bound는 여전히 그 세션을 가리켜,bound->handle이 해제된 세션을 건드린다. - 자원 누수:
close를 호출 안 해 옛 소켓/스레드/버퍼가 남는다.
- 이중 입력: 옛 연결과 새 연결이 동시에
- 재현 조건: 옛 연결의 수신 스레드가 살아있는 채로 재접속·소켓 정리가 겹칠 때
(모바일 재접속의 전형적 타이밍).
byToken_/conns_도 무보호라 맵 자체 레이스. - 근본 원인: 재바인딩을 원시 포인터 교체로만 처리하고, (a) 이전 연결의 세션 참조 해제, (b) 이전 연결의 안전한 종료(수신 스레드 조인 후 파괴), (c) 교체 구간 배제, (d) 소유권/수명 규칙을 모두 빠뜨림.
수정안
연결을 shared_ptr로 소유해 in-flight 콜백이 끝날 때까지 수명을 보장하고,
재바인딩을 락으로 원자화하며 이전 연결을 명시적으로 분리·종료한다.
bool reconnect(const std::string& token, std::shared_ptr<Connection> newConn) {
std::shared_ptr<Connection> old;
{
std::lock_guard<std::mutex> lk(mtx_);
auto it = byToken_.find(token);
if (it == byToken_.end()) return false;
Session* session = it->second;
old = session->connSp; // 이전 연결(공유 소유) 회수
if (old) old->bound = nullptr; // (1) 이전 연결의 세션 참조 끊기
session->connSp = newConn; // (2) 새 연결로 교체(shared_ptr)
newConn->bound = session;
rotateToken(token, session); // (4) 토큰 일회성 회전
}
if (old) old->close(); // (3) 이전 연결 종료(수신 루프 정지·조인)
return true;
}
그리고 수신 콜백은 자신이 현재 활성 연결인지 확인한다:
void Connection::onPacket(const std::vector<uint8_t>& data) {
Session* s = bound; // 지역 스냅샷
if (!s || s->connSp.get() != this) return; // 나는 더 이상 활성 연결이 아니다
s->handle(data);
}
핵심: ① shared_ptr 소유로 콜백 진행 중 파괴를 막아 UAF 제거, ② 이전 연결 bound
해제 + close로 이중 입력/누수 차단, ③ connSp.get() != this 재확인으로 경합 잔여
패킷 폐기, ④ 토큰 회전. close는 수신 스레드를 멈추고 조인한 뒤 반환하도록 구현한다.
더 나은 설계
- 세대(epoch) 태그: 바인딩마다 epoch를 부여하고 패킷 처리 전 epoch 비교로 옛 연결 입력을 O(1) 폐기(포인터 비교의 일반화, ABA 방지).
- 상태 머신:
Active/Suspended/Rebinding/Closed. Rebinding 중에는 양쪽 입력을 큐잉/차단해 인터리빙을 구조적으로 봉쇄. - 수명은 소유로, 관찰은 weak_ptr로: 세션이 연결을 관찰만 할 땐
weak_ptr로 잡고 사용 시lock()으로 승격해 파괴와의 경합을 안전하게 만든다. - 유예 타이머 취소: 재바인딩 성공 시 세션 파기 타이머를 반드시 취소.
면접 포인트
- "재접속을 원시 포인터 교체로만 하면 C++에서 뭐가 더 위험한가?" → GC가 없어 옛
연결/세션이 즉시 파괴될 수 있고, 진행 중 콜백이 그 메모리를 만지면 UAF. 소유는
shared_ptr, 관찰은weak_ptr, 종료는 수신 스레드 조인까지 포함해야 한다. - "shared_ptr면 스레드 안전한가?" → 제어블록 refcount는 원자적이나, 같은
shared_ptr인스턴스에 대한 동시 read/write와 가리키는 객체 상태는 별도 보호 필요.