기본 콘텐츠로 건너뛰기

Fixing a Crash in SNI Callbacks

thumbnail

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

댓글

이 블로그의 인기 게시물

Django에서 트랜잭션 관리하기

Django에서 트랜잭션 관리하기 안녕하세요! 오늘은 Django에서 데이터베이스 트랜잭션을 효과적으로 관리하는 방법에 대해 알아보겠습니다. 1. 트랜잭션의 중요성 트랜잭션은 데이터베이스의 일관성과 무결성을 보장하는 중요한 개념입니다. Django에서는 여러 가지 방법으로 트랜잭션을 관리할 수 있습니다. 1.1 기본 개념 원자성(Atomicity) : 트랜잭션은 모두 실행되거나 모두 실행되지 않아야 합니다. 일관성(Consistency) : 트랜잭션 전후로 데이터베이스의 일관성이 유지되어야 합니다. 격리성(Isolation) : 동시에 실행되는 트랜잭션들이 서로 영향을 주지 않아야 합니다. 지속성(Durability) : 완료된 트랜잭션의 결과는 영구적으로 저장되어야 합니다. 2. Django의 트랜잭션 관리 2.1 기본 설정 # settings.py DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', 'NAME': 'mydatabase', 'USER': 'myuser', 'PASSWORD': 'mypassword', 'HOST': 'localhost', 'PORT': '5432', 'ATOMIC_REQUESTS': True, # 모든 뷰를 트랜잭션으로 래핑 } } 2.2 데코레이터 사용 from django.db import transaction @transaction.atomic def create_order(user, items): order = Order.objects.create(user=...

AWS S3 + CloudFront로 정적 파일 서빙 완전 가이드

AWS S3 + CloudFront로 정적 파일 서빙 완전 가이드 안녕하세요! 오늘은 AWS S3와 CloudFront를 사용하여 정적 파일을 효율적으로 서빙하는 방법에 대해 알아보겠습니다. 왜 S3와 CloudFront를 사용할까요? 높은 가용성 : AWS의 글로벌 인프라를 활용 빠른 전송 속도 : CloudFront의 CDN 기능으로 전 세계 사용자에게 빠른 전송 비용 효율성 : 사용한 만큼만 지불 보안 : AWS의 보안 기능 활용 확장성 : 트래픽 증가에 자동 대응 1. S3 버킷 설정 1.1 버킷 생성 및 설정 import boto3 def create_s3_bucket(): s3 = boto3.client('s3') # 버킷 생성 bucket_name = 'your-static-files-bucket' s3.create_bucket( Bucket=bucket_name, CreateBucketConfiguration={ 'LocationConstraint': 'ap-northeast-2' } ) # 버킷 정책 설정 bucket_policy = { "Version": "2012-10-17", "Statement": [ { "Sid": "PublicReadGetObject", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObje...

RDS에서 Django 앱 성능을 높이는 데이터베이스 설정 팁

RDS에서 Django 앱 성능을 높이는 데이터베이스 설정 팁 안녕하세요! 오늘은 AWS RDS를 사용하는 Django 애플리케이션의 성능을 최적화하는 방법에 대해 알아보겠습니다. 1. RDS 인스턴스 최적화 1.1 인스턴스 타입 선택 # RDS 인스턴스 크기 조정 import boto3 def resize_rds_instance(): rds = boto3.client('rds') response = rds.modify_db_instance( DBInstanceIdentifier='your-db', DBInstanceClass='db.t3.large', # 워크로드에 맞는 인스턴스 타입 선택 ApplyImmediately=True ) return response['DBInstance'] 1.2 파라미터 그룹 설정 def create_parameter_group(): rds = boto3.client('rds') # PostgreSQL 파라미터 그룹 생성 response = rds.create_db_parameter_group( DBParameterGroupName='django-optimized', DBParameterGroupFamily='postgres13', Description='Optimized parameters for Django applications' ) # 성능 관련 파라미터 설정 parameters = [ { 'ParameterName': 'shared_buffers', 'ParameterValue': '2GB...