TLS 1.3 0-RTT와 HTTP 425 Too Early: 재전송 공격을 서버에서 막는 방법

TLS 1.3 early data는 연결 간 재전송이 없다는 보장을 주지 않습니다. RFC 8446과 RFC 8470을 바탕으로 0-RTT의 한계, Early-Data 헤더, 425 응답을 언제 어떻게 쓰는지 정리합니다.

TLS 1.30-RTTHTTP 425재전송 공격웹 보안

TLS 1.3의 0-RTT(early data)를 켜면 재방문한 클라이언트는 핸드셰이크가 끝나기 전에 첫 HTTP 요청을 보낼 수 있습니다. 대가로 그 요청은 공격자가 복사해 다시 보낼 수 있는 상태가 됩니다. RFC 8446은 이 데이터에 대해 “연결 간 재전송이 일어나지 않는다는 보장이 없다”고 명시합니다. 이 글은 그 위험이 정확히 어디서 생기는지, 그리고 RFC 8470이 정의한 Early-Data 헤더와 425 Too Early 상태 코드로 서버가 무엇을 할 수 있는지를 표준 문서 기준으로 정리합니다.

0-RTT는 어떻게 동작하는가

RFC 8446 2.3절에 따르면 클라이언트와 서버가 PSK(pre-shared key)를 공유하고 있을 때, 클라이언트는 첫 번째 메시지 묶음(first flight)에 애플리케이션 데이터를 함께 실어 보낼 수 있습니다. PSK는 외부에서 미리 배포했거나 이전 핸드셰이크에서 받은 세션 티켓으로 얻습니다. 세션 티켓을 쓰는 후자가 세션 재개(resumption)입니다.

Client                                   Server
ClientHello
+ early_data
+ pre_shared_key
(Application Data*)   -------->
                                     ServerHello
                               {EncryptedExtensions}
                                      + early_data*
                                      {Finished}
                      <--------  [Application Data*]
(EndOfEarlyData*)
{Finished}            -------->

괄호 ()로 표시된 early data는 PSK에서 파생한 키로만 암호화됩니다. 서버의 ServerHello를 기다리지 않는다는 점이 속도의 원천이자 약점의 원천입니다.

*는 상황에 따라 보내지는 메시지나 확장을 뜻합니다. RFC 8446 4.5절에 따르면 클라이언트는 서버가 EncryptedExtensions에 early_data 확장을 넣어 early data를 수락했을 때만 EndOfEarlyData를 보내고, 그렇지 않으면 보내면 안 됩니다(MUST NOT). RFC 8446 원문 그림에는 (EndOfEarlyData)에 *가 없지만, 이를 바로잡는 정오표(Errata ID 8794)가 “Held for Document Update” 상태로 등록되어 있어 위 그림에 반영했습니다.

같은 절은 0-RTT 데이터의 보안 성질이 일반 TLS 데이터보다 약하다고 경고합니다.

  • PSK에서만 파생한 키로 암호화되므로 전방 비밀성(forward secrecy)이 없습니다.
  • 일반 1-RTT 데이터는 서버의 Random 값 덕분에 재전송 보호를 받지만, 0-RTT 데이터는 ServerHello에 의존하지 않으므로 연결 간 재전송 방지 보장이 없습니다. RFC는 TLS 클라이언트 인증이나 애플리케이션 프로토콜 안의 인증으로 보호되는 데이터일수록 이 점이 중요하다고 덧붙입니다.

반대로 보장되는 것도 있습니다. 같은 연결 안에서 서버가 같은 0-RTT 데이터를 두 번 처리하지는 않고, 공격자가 0-RTT 데이터를 1-RTT 데이터처럼 위장할 수도 없습니다(서로 다른 키로 보호되기 때문입니다).

TLS가 막아 주는 것과 못 막는 것

RFC 8446 8절은 위협을 두 종류로 나눕니다.

  1. 단순 복제: 네트워크 공격자가 0-RTT 데이터 묶음을 그대로 복사해 다시 보냅니다.
  2. 클라이언트 재시도 악용: 서버가 여러 존(zone)으로 나뉘어 상태를 공유하지 않는다면, 공격자는 같은 ClientHello와 early data를 A존과 B존 양쪽에 보낼 수 있습니다. A는 0-RTT를 받아 처리하고, B는 0-RTT를 거부하고 전체 핸드셰이크를 진행합니다. 공격자가 A의 ServerHello를 막으면 클라이언트는 B와 핸드셰이크를 마치고 요청을 다시 보낼 가능성이 높습니다. 결과적으로 시스템 전체에서는 요청이 두 번 처리됩니다.

첫 번째 위협에 대해 RFC는 서버가 상태를 공유해 0-RTT 데이터를 최대 한 번만 받도록 하는 것을 SHOULD로 권고하고, 8.1~8.3절에서 방법을 제시합니다.

  • 단일 사용 티켓(8.1): 유효한 티켓을 저장해 두고 사용되면 삭제합니다. 분산 환경에서는 세션 DB를 공유해야 해서 0-RTT 성공률을 높게 유지하기 어려울 수 있습니다.
  • ClientHello 기록(8.2): ClientHello에서 파생한 고유값(대개 random 값이나 PSK binder)을 일정 시간 창 안에서 기록하고 중복을 거부합니다. 막 시작한 서버는 기록 창이 시작 시각과 겹치는 동안 0-RTT를 거부해야 한다(SHOULD)고 되어 있습니다.
  • 신선도 검사(8.3): 티켓 나이를 이용해 오래된 ClientHello의 0-RTT를 거부합니다. RFC는 인터넷 클라이언트라면 시계 오차와 측정 편차 때문에 대략 10초 단위의 허용 창이 필요하다고 설명하면서도, 신선도 검사만으로는 그 창 안의 재전송을 잡지 못하므로 충분하지 않다고 명시합니다.

또한 서버는 같은 0-RTT 핸드셰이크에 대해 각 인스턴스(머신, 스레드 등)가 0-RTT를 최대 한 번만 수락하도록 해야 합니다(MUST). 이것은 재전송 횟수를 “서버 인스턴스 수”로 제한하는 최소 요건일 뿐, 재전송이 없다는 뜻이 아닙니다.

두 번째 위협에 대해서는 문장이 분명합니다. 이 공격은 TLS 계층에서 막을 수 없으며 애플리케이션이 처리해야 합니다(MUST). 부록 E.5도 8절의 메커니즘이 클라이언트 데이터를 여러 번 받는 상황을 완전히 막아 주지는 않는다고 정리합니다. 거기서 든 공격 예시는 다음과 같습니다.

  • 구매나 송금처럼 부수 효과가 있는 동작의 중복 실행
  • 저장해 둔 0-RTT 메시지를 재전송해 순서를 뒤바꾸기(예: create 뒤로 delete를 옮기기)
  • 다른 캐시 노드에 재전송한 뒤 응답 지연을 측정해 요청 내용을 추정하는 캐시 타이밍 공격
  • 대량 재전송이 가능할 경우 암호 연산 시간 측정, 속도 제한(rate limit) 시스템 과부하

정리하면, TLS 스택의 anti-replay는 재전송 횟수를 줄이는 장치이고, “이 요청이 한 번만 처리된다”는 보장은 애플리케이션 몫입니다.

RFC 8470: HTTP 서버가 쓸 수 있는 세 가지 선택

RFC 8470 3절은 early data를 허용한 서버가 재전송 위험을 줄이는 방법으로 세 가지를 나열하고, 셋 다 똑같이 효과적이라고 말합니다.

  1. TLS 계층에서 early data 거부: TLS는 early data를 전부 받거나 전부 거부하는 것만 허용하므로, 이 경우 early data로 온 모든 요청이 버려집니다.
  2. 핸드셰이크 완료까지 처리 보류: 핸드셰이크가 성공적으로 끝난 연결의 요청만 처리합니다. 요청별로 보류 여부를 정할 수 있습니다.
  3. 425 (Too Early) 응답: 재전송 위험이 크다고 판단한 개별 요청에 대해 클라이언트가 early data 없이 다시 보내도록 합니다.

여기서 중요한 문장이 이어집니다. 어떤 요청이 재전송을 견딜 수 있는지는 리소스마다 다르며 오리진 서버만 압니다. 메서드의 안전성(safe 여부)이 한 가지 판단 기준이지만, 안전한 메서드로도 부수 효과를 내는 리소스가 있으므로 이것만 믿을 수는 없습니다. 그래서 RFC 8470은 오리진 서버가 리소스별로 early data 허용 여부를 명시적으로 설정할 수 있게 하라고 권고(RECOMMENDED)하고, 그런 정보가 없다면 early data를 거부하거나 핸드셰이크 완료 전에는 요청을 처리하지 않아야 한다(MUST)고 규정합니다.

요청 일부만 early data로 오고 나머지는 핸드셰이크 뒤에 오는 경우도 있습니다. 판단 기준은 “언제 받았나”가 아니라 서버가 요청 내용에 따라 행동을 시작하는 시점입니다. 헤더를 파싱하고 라우팅을 결정하는 정도는 부수 효과가 없을 가능성이 높지만, 그 이상은 경우에 따라 다릅니다.

클라이언트 쪽 규칙

RFC 8470 4절에 따르면 다른 정보가 없을 때 클라이언트는 안전한 메서드의 요청만 early data로 보낼 수 있고(MAY), 안전하지 않거나 안전성을 모르는 메서드는 보내면 안 됩니다(MUST NOT). RFC 9110 9.2.1절 기준으로 안전한 메서드는 GET, HEAD, OPTIONS, TRACE입니다.

클라이언트가 early data를 쓴다면 425를 받았을 때 요청을 재시도해야 합니다(MUST). 5.2절은 사용자 에이전트가 자동으로 재시도하는 것을 권고(SHOULD)하되, 재시도는 early data로 보내면 안 된다(MUST NOT)고 합니다.

RFC 8446 부록 E.5는 TLS 구현에도 제약을 둡니다. 애플리케이션이 명시적으로 요청하지 않는 한 0-RTT를 켜면 안 되고, 서버가 거부했을 때 애플리케이션 지시 없이 0-RTT 데이터를 자동으로 재전송해서도 안 됩니다. 또 애플리케이션이 핸드셰이크 완료 여부를 확인할 방법을 제공해야 합니다. 서버 애플리케이션이 “보류” 전략을 구현할 수 있는 근거가 이 요건입니다.

프록시와 CDN 뒤에 있을 때: Early-Data 헤더

TLS를 CDN이나 리버스 프록시에서 종료하면 오리진은 요청이 early data로 왔는지 알 수 없습니다. 반대로 게이트웨이는 그 요청이 재전송돼도 괜찮은지 판단할 정보가 없습니다. RFC 8470 5절의 Early-Data 헤더와 425가 이 간극을 메웁니다.

GET /resource HTTP/1.1
Host: example.com
Early-Data: 1

규칙은 다음과 같습니다.

  • 클라이언트와의 TLS 핸드셰이크가 끝나기 전에 요청을 전달하는 중개자는 Early-Data: 1을 붙여야 합니다(MUST). 이미 있는 헤더를 제거해서도 안 됩니다.
  • 유효한 값은 1 하나뿐입니다. 여러 개이거나 잘못된 값이 와도 서버는 1 하나로 취급해야 합니다.
  • 이 헤더는 중개자용입니다. early data로 요청을 보낸 사용자 에이전트는 붙일 필요가 없습니다. early data로 보냈다는 사실 자체가 425를 이해하고 재시도할 의사가 있다는 뜻이기 때문입니다.
  • 오리진이 핸드셰이크 완료를 기다려도 Early-Data: 1 요청은 안전해지지 않습니다. 앞 홉에서 이미 early data로 왔기 때문입니다. 안전하게 처리할 수 없다면 425로 거부해야 합니다(MUST).
  • 응답이나 요청 트레일러에는 이 헤더를 넣으면 안 됩니다.

게이트웨이 쪽 의무도 있습니다(6.1절). 오리진이 Early-Data 헤더를 이해하고 425를 올바르게 생성한다는 것을 모르면 early data로 받은 요청을 전달하면 안 됩니다(MUST NOT). 확신이 없으면 핸드셰이크 완료까지 전달을 미루거나 직접 425를 보내야 합니다(SHOULD). 425 응답을 받았을 때, 중개자가 받아서 전달한 요청에 이미 Early-Data 헤더가 있었다면 425를 그대로 전달해야 합니다(MUST). 중개자가 직접 early data로 받은 요청이라면 그 연결의 TLS 핸드셰이크 완료를 기다린 뒤 스스로 재시도할 수도 있습니다(MAY).

오리진에서의 처리 예

아래는 특정 프레임워크가 아닌 의사 코드입니다. 핵심은 “early data로 처리해도 되는 경로”를 명시적으로 허용 목록으로 관리하는 것입니다.

# early data로 와도 처리해도 된다고 리소스 소유자가 판단한 경로만 등록
EARLY_DATA_OK = {
    ("GET", "/api/catalog"),
    ("GET", "/static/app.js"),
}

# request.tls.used_early_data는 설명용 가상 속성이며 실제 API가 아님.
# 이 요청의 바이트가 하나라도 TLS early data로 왔다면 True.
def handle(request):
    via_early_data = (
        request.headers.get("Early-Data") is not None  # 앞 홉에서 early data
        or request.tls.used_early_data                  # 이 서버가 직접 TLS 종료
    )
    if via_early_data and (request.method, request.route) not in EARLY_DATA_OK:
        return Response(status=425)  # 클라이언트가 early data 없이 재시도
    return route(request)

몇 가지 주의할 점이 있습니다.

  • request.tls.used_early_data는 자리표시자입니다. 실제로는 사용하는 TLS 스택이 요청마다 “요청 바이트 중 early data로 받은 것이 있는지”를 기록해 제공해야 합니다. “핸들러가 실행되는 시점에 핸드셰이크가 아직 끝나지 않았는가”로 대신하면 안 됩니다. early data로 온 요청이 핸드셰이크 완료까지 버퍼에 있다가 넘어올 수도 있고, 일부만 early data로 오고 나머지는 핸드셰이크 뒤에 올 수도 있으며(3절), 순서 없이 전달되는 프로토콜에서는 핸드셰이크 완료 뒤에 도착할 수도 있습니다(6.4절). 이런 경우 그 조건은 거짓이 되어 재전송에 민감한 요청이 그대로 처리됩니다.
  • Early-Data 헤더는 값이 이상해도 1로 간주해야 하므로 값 비교보다 존재 여부로 판단하는 편이 맞습니다.
  • 이 서버가 TLS를 직접 종료한다면 425 대신 핸드셰이크 완료까지 처리를 보류하는 선택지도 있습니다. 핸드셰이크 완료 여부는 이때 보류한 처리를 재개해도 되는지 판단하는 데만 쓰고, early data 여부 판별에는 쓰지 않습니다. 또 Early-Data: 1이 붙은 요청에는 보류가 효과가 없습니다.
  • 서버는 요청이 early data로 왔거나 Early-Data: 1이 있을 때만 425를 보내야 합니다(SHOULD NOT emit otherwise). 그 외의 클라이언트는 재시도할 수 있다는 보장이 없습니다.
  • 425 응답은 기본적으로 캐시되지 않습니다.
  • GET이라도 조회수 증가, 쿠폰 발급, 로그인 링크 소비처럼 상태를 바꾸는 경로는 허용 목록에 넣지 않습니다.

운영 시 확인할 점

인스턴스 간 일관성. RFC 8470 6.2절은 핸드셰이크 전에 처리하면 안전하지 않은 요청에 대해 게이트웨이를 포함한 모든 서버 인스턴스가 같은 판단을 해야 한다고 강조합니다. 거부, 보류, 425는 똑같이 좋은 대응이라 인스턴스마다 다른 방법을 써도 일관적인 것으로 봅니다. 다만 같은 데이터를 다른 인스턴스가 다르게 처리할 수 있다면 핸드셰이크 완료 전에 그 데이터로 행동하면 안 됩니다(MUST NOT).

부하 상황. 6.3절은 부하가 걸린 서버라면 early data를 받아 놓고 골라 처리하기보다 TLS 계층에서 통째로 거부하는 편을 택해야 한다고 권고합니다(SHOULD). 503이나 425를 보내면 클라이언트 재시도로 부하가 오히려 늘 수 있기 때문입니다.

쓸모 있는 오리진이 없을 때. 6.1절은 Early-Data 헤더를 지원하는 오리진이 하나도 없다면 게이트웨이에서 early data를 끄는 편이 효율적이라고 말합니다. 얻는 성능 이득은 기껏해야 크지 않은데 들이는 노력은 크다는 것이 RFC의 표현입니다.

HTTP/3. RFC 8470은 이 조언이 QUIC 위 HTTP의 0-RTT에도 적용된다고 밝힙니다. QUIC처럼 순서 없이 전달되는 프로토콜에서는 early data가 핸드셰이크 완료 뒤에 도착할 수도 있고, 이 경우 다른 인스턴스가 같은 요청의 재전송을 올바르게 처리한다고 믿을 수 있을 때만 처리할 수 있습니다(6.4절).

425만으로는 부족한 이유

425와 Early-Data는 “early data 구간에서 처리하지 않기”를 위한 장치입니다. RFC 8446 8절이 말한 두 번째 위협, 즉 0-RTT 거부 후 클라이언트가 1-RTT로 다시 보내는 요청과 원래 존에서 이미 처리된 요청이 겹치는 상황은 HTTP 재시도 일반의 문제이기도 합니다. RFC 8446도 재시도 동작이 있는 애플리케이션은 이미 어떤 형태든 재전송 방어가 필요하다고 적고 있습니다.

결제, 주문, 송금처럼 중복 실행이 곤란한 POST 엔드포인트라면 early data 차단과 별도로 애플리케이션 수준의 중복 제거가 필요합니다. 클라이언트가 생성한 요청 식별자(흔히 idempotency key라 부르는 방식)를 서버에 저장해 같은 키의 두 번째 요청에는 첫 결과를 돌려주는 방식이 실무에서 자주 쓰입니다. 이 방식은 RFC 8446이나 RFC 8470이 정의한 것이 아니라 실무 관행이며, 메서드 의미론 자체는 HTTP 멱등 메서드 정리에서 다뤘습니다.

점검 목록

  • 사용하는 TLS 종료 지점(CDN, 로드 밸런서, 웹 서버)에서 0-RTT가 켜져 있는지 확인합니다.
  • 켜져 있다면 오리진이 Early-Data 헤더를 받는지, 425를 생성할 수 있는지 확인합니다. 둘 다 아니라면 early data를 끄는 쪽이 RFC 8470의 권고에 가깝습니다.
  • early data로 처리해도 되는 경로를 허용 목록으로 명시합니다. 메서드만으로 판단하지 않습니다.
  • 모든 인스턴스가 같은 규칙을 쓰는지 확인합니다.
  • 중복 실행이 곤란한 작업은 early data 설정과 무관하게 애플리케이션 수준에서 중복 제거를 합니다.

참고한 표준

RFC 8470은 메서드 안전성 정의로 RFC 7231 4.2.1절을 인용하는데, RFC 7231은 현재 RFC 9110으로 대체되었으므로 본문에서는 RFC 9110의 해당 절을 기준으로 삼았습니다. 특정 서버나 CDN 제품의 설정 방법과 기본값은 제품과 버전마다 다르므로 이 글에서 다루지 않았습니다.

블로그로