![]()
CPython: SNI 콜백에서 ‘죽은 SSL 객체’ 참조로 크래시 나던 케이스가 고쳐졌다(gh-146080)
meta_description: CPython에서 SNI(server_name) 콜백이 호출되는 타이밍에 SSLSocket/SSLObject가 이미 GC로 사라진 경우, 내부 C 콜백이 NULL을 DECREF 하며 크래시할 수 있던 버그가 수정됐다(gh-146080). 이 글은 어떤 상황에서 발생하는지, 파이썬 코드 레벨에서 무엇을 조심해야 하는지, 그리고 라이브러리 작성자가 테스트로 재현/회귀 방지하는 방법을 정리한다. meta_keywords: python,cpython,ssl,sni,servername callback,wrap_bio,MemoryBIO,garbage collection,crash,segfault,SSLSocket,SSLObject,OpenSSL,AWS-LC,핸드셰이크,약한참조,콜백,테스트,회귀 meta_robots: index,follow
SNI(Server Name Indication) 콜백은 “TLS 핸드셰이크 도중, 클라이언트가 보낸 server name(hostname)에 따라 인증서/컨텍스트를 바꾸고 싶다” 같은 고급 케이스에서 사용된다.
문제는 이 콜백이 엄청 이른 시점(핸드셰이크 중간)에 실행되고, 구현 실수로 SSLSocket/SSLObject의 생명주기를 잘못 다루면 “파이썬 예외”가 아니라 프로세스 크래시로 튈 수 있다는 점이다.
지난 72시간 내 merged 된 CPython 변경(gh-146080)은 딱 그 모서리를 하나 메운다.
- SNI 콜백이 호출되려는 순간
- 콜백의 소유자(SSLSocket/SSLObject)가 이미 GC로 사라져
- 내부 포인터가 NULL이 될 수 있는 상황에서
- NULL을
Py_DECREF()하며 크래시할 수 있던 부분을 Py_XDECREF()로 바꿔서 “정상적인 SSL 에러”로 처리하게 했다.
![]()
1) 무엇이 고쳐졌나: 한 줄이지만 의미가 큰 변경
PR diff에서 핵심은 사실 한 줄이다.
- 기존:
Py_DECREF(ssl_socket); - 변경:
Py_XDECREF(ssl_socket);
Py_XDECREF()는 포인터가 NULL일 수 있는 경우를 안전하게 처리한다.
즉, “원래는 절대 NULL이면 안 되는 경로”라고 믿고 있던 포인터가, 특정 생명주기/참조 조건에서 NULL이 될 수 있다는 걸 CPython이 인정하고 안전장치를 박은 셈이다.
이게 왜 중요하냐면, 이런 종류의 버그는 재현이 어려워서 운 좋으면 몇 달 동안 안 터지고, 운 나쁘면 트래픽 피크 때 한 번에 터진다.
2) 어떤 상황에서 터지나: SNI 콜백 + dead weakref + 내부 SSL 객체
이 PR에 함께 들어간 테스트(test_sni_callback_on_dead_references)가 상황을 정확히 보여준다.
핵심 포인트는 3개다.
1) 서버측 컨텍스트에 SNI 콜백을 걸어둔다 (SSLContext.set_servername_callback)
2) wrap_bio() + MemoryBIO로 핸드셰이크를 진행한다 (라이브러리/프록시에서 흔함)
3) “서버 wrapper 객체”는 지워버리되, 내부 _sslobj만 들고 계속 핸드셰이크를 밀어본다
이때 서버 컨텍스트가 내부적으로 들고 있는 것은 “소유자”에 대한 약한 참조(weak reference)일 수 있고, 소유자가 이미 GC로 사라졌다면 콜백이 실행되는 시점에 소유자 포인터가 비어버린다.
원래라면 이런 건 파이썬 레벨에서 SSLError로 끝나야 하는데, C 레벨에서 NULL DECREF 같은 사고가 나면 그대로 크래시로 간다.
3) 라이브러리/프레임워크 작성자에게 실전 체크리스트
이 버그는 “일반 사용자가 requests.get() 하다가 갑자기 터지는” 류는 아니다.
대신 아래 같은 코드를 작성하는 팀(혹은 라이브러리)이면, 영향권이다.
ssl.MemoryBIO()+SSLContext.wrap_bio()로 TLS를 직접 구동한다SSLSocket/SSLObject수명주기를 프레임워크가 관리한다- 성능/구조 때문에 내부
_sslobj를 들고 직접 핸드셰이크를 굴린다(가능하면 피해야 함) - 서버 네임 기반으로 인증서 선택을 하려고 SNI 콜백을 쓴다
체크리스트(사고 예방용)
- 핸드셰이크가 끝날 때까지 wrapper 객체를 강하게 참조(strong ref)로 유지한다
- “내가
_sslobj만 들고 있으면 되겠지”는 위험하다
- “내가
- SNI 콜백은 “컨텍스트 교체/인증서 선택”만 하고, 상태를 오래 잡지 않는다
- 콜백 안에서 외부 객체(전역 캐시, 연결 풀, 로거)를 건드릴 때는 예외/지연을 최소화한다
- 테스트에서 “객체가 GC로 사라지는 경로”를 일부러 만들어서 회귀를 잡는다
4) 최소 재현(학습용): wrap_bio + SNI 콜백 + 소유자 삭제
아래 코드는 개념 이해용이다(실제로는 테스트 인증서/호스트명이 필요하다). 하지만 구조는 PR 테스트와 동일하다.
import gc
import ssl
# server_ctx = ... (server_side=True, certfile 세팅)
# client_ctx = ...
def sni_cb(sock, servername, ctx):
# 여기서 servername에 따라 ctx.load_cert_chain(...) 같은 걸 한다
pass
server_ctx.set_servername_callback(sni_cb)
c_in, c_out = ssl.MemoryBIO(), ssl.MemoryBIO()
client = client_ctx.wrap_bio(c_in, c_out, server_hostname="example.com")
s_in, s_out = ssl.MemoryBIO(), ssl.MemoryBIO()
server = server_ctx.wrap_bio(s_in, s_out, server_side=True)
# 여기서 client 쪽 handshake를 먼저 진행해서 server 쪽에 데이터를 넣고...
# (중략)
server_impl = server._sslobj
del server
gc.collect()
# 내부 구현 객체만으로 handshake를 계속 밀면, 소유자가 죽어 weakref가 dead가 될 수 있음
server_impl.do_handshake()
포인트는 “이렇게 해도 안전하게 ssl.SSLError로 떨어져야 한다”는 것이다.
이번 수정으로 적어도 CPython 쪽에서는 “죽은 포인터를 DECREF 하다가 크래시”하는 경로를 줄였다.
5) 내가 뭘 해야 하나: 사용자/운영 관점
- 일반 사용자(ssl을 직접 안 만짐): 당장 할 건 없다.
- TLS를 직접 구동하는 라이브러리/서비스:
- (1) 핸드셰이크 동안 객체 수명주기 보장(강한 참조 유지)
- (2) SNI 콜백 경로에서 예외/GC/참조 끊김이 발생하지 않는지 테스트 추가
- (3) 새 파이썬 버전(해당 변경 포함) 릴리스 이후 업그레이드 계획에 반영
이건 “성능 개선”이 아니라 프로세스 안정성(크래시 방지) 쪽 이슈라서, 배포 우선순위를 높게 잡는 게 보통 이득이다.
6) 장애 조사에 바로 쓰는 힌트: OpenSSL vs AWS-LC에서 메시지가 다를 수 있다
이 PR의 테스트는 재미있는 현실을 하나 더 보여준다. 같은 “SNI 콜백 관련 실패”여도, 사용 중인 TLS 라이브러리에 따라 에러 텍스트가 달라질 수 있다.
- OpenSSL 쪽은 보통
callback failed류의 이유(reason)를 기대한다 - AWS-LC 환경에서는 핸드셰이크 실패가 좀 더 뭉뚱그려져
PARSE_TLSEXT같은 문자열로 보일 수 있다
실무에서 이 차이는 꽤 중요하다.
- 모니터링에서 에러 문자열로 알림을 걸어두면(예: grep 기반)
- 같은 사건인데도 환경에 따라 알림이 안 뜨거나, 반대로 잡음이 늘 수 있다
그래서 추천하는 방식은 “문자열”만 보지 말고, 가능하면 ssl.SSLError의 errno/종류를 함께 로깅하는 것이다.
예:
import ssl
try:
...
except ssl.SSLError as e:
logger.warning("ssl error", extra={"errno": getattr(e, "errno", None), "msg": str(e)})
raise
이렇게 해두면 런타임이 OpenSSL이든 AWS-LC든, “같은 급의 실패”를 같은 기준으로 묶어서 볼 수 있다.
7) ‘내 코드’에서 가장 흔한 실수: 내부 구현 객체만 잡고 wrapper를 놓는 패턴
wrap_socket()/wrap_bio()가 만들어주는 객체(SSLSocket/SSLObject)는 단순한 껍데기가 아니라, C 레벨의 상태와 콜백 연결을 관리하는 소유자(owner) 역할도 한다.
그래서 아래 패턴은 특히 위험하다.
- “성능 때문에” 내부
_sslobj를 꺼내서 보관한다 - 래퍼 객체는 더 이상 필요 없다고 판단해 참조를 끊는다
- 그런데 핸드셰이크는 아직 진행 중이다(혹은 지연되어 나중에 진행된다)
이런 코드는 로컬에서는 잘 도는 것처럼 보이는데, 트래픽/스케줄링/GC 타이밍에 따라 아주 가끔 터진다.
안전한 쪽으로 기울이면 해법은 간단하다.
- 핸드셰이크가 끝날 때까지는 래퍼 객체를 리스트/클로저 등으로 강하게 잡아둔다
- 내부 구현을 직접 만지는 대신, 가능한 한 공개 API로만 핸드셰이크를 진행한다
- “객체가 살아있어야 한다”는 조건을 코드에 주석이 아니라 테스트로 박아둔다
이 PR이 추가한 테스트가 바로 그 테스트의 모양을 제시해준다.
Keywords
python,cpython,ssl,sni,servername callback,wrap_bio,MemoryBIO,garbage collection,crash,segfault,SSLSocket,SSLObject,OpenSSL,AWS-LC,handshake,weakref,콜백,테스트,회귀
이미지 크레딧/라이선스
- Laptop coding programs (Unsplash).jpg — Tirza van Dijk / CC0
- https://commons.wikimedia.org/wiki/File:Laptop_coding_programs_(Unsplash).jpg
- http://creativecommons.org/publicdomain/zero/1.0/deed.en
댓글
댓글 쓰기