모종닷컴

Docker 이미지 변경 후 TLS Handshake가 깨졌다 (2) — 정말 TLS_RSA_* 하나가 원인의 전부였을까? 본문

카테고리 없음

Docker 이미지 변경 후 TLS Handshake가 깨졌다 (2) — 정말 TLS_RSA_* 하나가 원인의 전부였을까?

모종 2026. 7. 13. 20:12
반응형

지난 글에서 Docker 이미지를 변경한 뒤 외부 서비스와의 TLS Handshake가 실패했고, JDK 17.0.18부터 java.security의 jdk.tls.disabledAlgorithms에 TLS_RSA_*가 추가된 것이 직접적인 원인이라고 판단했습니다.

운영 장애를 빠르게 복구해야 했기 때문에 -Djava.security.properties를 이용해 TLS 정책을 재정의했고, 실제로 문제도 해결되었습니다.

하지만 장애가 해결된 이후에도 한 가지 의문이 계속 머릿속을 떠나지 않았습니다.

정말 TLS_RSA_* 하나만이 원인이었을까?

해당 외부 서비스는 정말 TLS_RSA_*밖에 사용할 수 없는 서버였을까?


첫 번째 단서: Cipher Suite 재확인

가장 먼저 해당 서버가 실제로 어떤 Cipher Suite를 지원하는지 다시 확인했습니다.

sslscan으로 TLSv1.2에서 지원하는 Cipher를 확인해 보니 1편과 같은 결과를 다시 볼 수 있었습니다.

Preferred TLSv1.2  AES128-GCM-SHA256
Accepted  AES256-GCM-SHA384
Accepted  AES128-SHA
Accepted  AES256-SHA
Accepted  DHE-RSA-AES128-GCM-SHA256
Accepted  DHE-RSA-AES256-GCM-SHA384
Accepted  DHE-RSA-AES128-SHA256
Accepted  DHE-RSA-AES256-SHA256
...

지난 글에서 살펴봤던 disabledAlgorithms는 Forward Secrecy가 없는 정적 RSA Key Exchange(TLS_RSA_*)만 비활성화합니다.

그런데 서버는 TLS_DHE_RSA_* 계열도 지원하고 있었습니다.

즉,

굳이 TLS_RSA_*를 사용하지 않아도 TLS Handshake가 가능해야 하는 것 아닌가?

라는 새로운 의문이 생겼습니다.


JSSE로 직접 연결해 보자

가설을 검증하기 위해 Spring Boot와 Reactor Netty를 모두 제거하고 순수 JSSE만 사용하여 TLS 연결을 시도했습니다.

HANDSHAKE SUCCESS
cipher=TLS_DHE_RSA_WITH_AES_128_CBC_SHA256
protocol=TLSv1.2

JDK 17.0.19에서도 TLS_DHE_RSA_*는 아무 문제 없이 사용할 수 있었습니다.

이 결과를 보는 순간 이런 생각이 들었습니다.

JVM 보안 정책을 우회할 필요가 없었던 것 아닐까?

Forward Secrecy를 지원하는 TLS_DHE_RSA_*만 사용하면 disabledAlgorithms를 건드리지 않아도 충분할 것처럼 보였습니다.


WebClient 설정하면서 Cipher Suite가 대체되었을까?

외부 서비스와의 통신을 위해 서버는 webClient를 사용하고 있었습니다. 따라서 다음으로 의심이 가는 부분은 WebClient 설정에서 Cipher Suite가 대체가 된 것이 아닐까 싶었습니다. 

@Bean
fun customClientHttpConnector(): ClientHttpConnector {
    val sslContext =
        SslContextBuilder.forClient()
            .protocols("TLSv1.2")
            ...
            .build()
    val httpClient = HttpClient.create().secure { it.sslContext(sslContext) }
    return ReactorClientHttpConnector(httpClient)
}

...

private val webClient =
    webClientBuilder
        .baseUrl("xxx.xxx.xxx")
        .clientConnector(customClientHttpConnector)
        .exchangeStrategies(
            ExchangeStrategies.builder()
                .codecs { it.defaultCodecs().maxInMemorySize(MAX_BUFFER_BYTES) }
                .build(),
        )
        .build()

기존 코드 Netty의 SslContextBuilder로 이 상태의 cipher 후보를 직접 찍어봤습니다.

===== Netty engine enabled cipher suites (no explicit .ciphers()) =====
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
Exception in thread "main" javax.net.ssl.SSLHandshakeException: (handshake_failure) Received fatal alert: handshake_failure

ECDHE 계열 6종뿐이었습니다. 이전에 sslscan 목록에 ECDHE가 단 하나도 없었습니다 — 전부 RSA 정적 키교환 또는 DHE_RSA 뿐이었습니다. 그렇다면 JDK TLS 정책 문제와 상관없이 SslContextBuilder의 Cipher Suite 목록에서 이미 협의할 Cipher가 없기에 TLS 통신이 안된 거라고 생각했습니다.

근데 의문이 생겼습니다. 이전 코드에서도 WebClient를 사용해서 통신했었는데 이전에는 왜 실패하지 않았을까? 

이전 코드에서는 왜 정상 동작했을까?

여기서 저의 큰 착각이 드러났습니다. 원래 운영 코드에는 TLS 설정이 전혀 없었습니다.

이슈를 해결하는 과정에서 자연스럽게 SslContextBuilder를 사용하게 되었는데, 이전에는 SslContextBuilder 기반의 ClientHttpConnector를 사용하지 않고 있었습니다. 

@Bean
fun customWebClient(): WebClient =
    WebClient.builder()
        .baseUrl("xxx.xxx.xxx")
        .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
        .build()

그렇다면 이전에는 기본 설정(Spring Boot가 만드는 순정 WebClient)으로 정상 동작했다는 뜻입니다.


기본 설정 Cipher를 확인해 보자

reactor-netty의 HttpClient.create()가 .secure()를 아예 호출하지 않을 때 실제로 쓰는 SslProvider.defaultClientProvider()를 직접 뽑아봤습니다.

===== reactor-netty SslProvider.defaultClientProvider() enabled cipher suites =====
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
TLS_RSA_WITH_AES_128_GCM_SHA256      ← Netty raw 기본값엔 없던 항목
TLS_RSA_WITH_AES_128_CBC_SHA        ← 
TLS_RSA_WITH_AES_256_CBC_SHA        ←
TLS_AES_128_GCM_SHA256   (TLS 1.3)
TLS_AES_256_GCM_SHA384   (TLS 1.3)
[real socket] handshake SUCCESS, cipher=TLS_RSA_WITH_AES_128_GCM_SHA256, protocol=TLSv1.2

Netty를 직접 호출했을 때의 기본 cipher 목록과, reactor-netty(Spring WebClient가 실제로 쓰는 경로)의 기본 cipher 목록은 서로 달랐습니다. 후자는 RSA-static 계열까지 포함하고 있고, 그래서 외부 서비스가 가장 선호하는 cipher(AES128-GCM-SHA256)로 정상 협상이 됐습니다. "cipher를 아무것도 안 건드린" 원래 코드가 실제로 작동했던 이유가 바로 이거였습니다.


마지막 확인: JDK 패치가 정말 이 목록을 바꾸는가

같은 테스트 코드를 로컬 JDK(17.0.16, 지난 글에서 비교했던 그 버전)와, 실제 운영과 동일한 eclipse-temurin:17-jre-jammy(JDK 17.0.19+10) 컨테이너에서 각각 테스트해 봤습니다.

로컬 (JDK 17.0.16)

TLS_RSA_WITH_AES_128_GCM_SHA256   ✅ 있음
TLS_RSA_WITH_AES_128_CBC_SHA      ✅ 있음
TLS_RSA_WITH_AES_256_CBC_SHA      ✅ 있음
→ handshake SUCCESS

jammy (JDK 17.0.19+10, 운영과 동일)

TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
TLS_AES_128_GCM_SHA256
TLS_AES_256_GCM_SHA384
→ SSLHandshakeException: (handshake_failure) Received fatal alert: handshake_failure

똑같은 코드인데, JDK 패치 버전만 다르게 돌렸더니 enabledCipherSuites 목록에서 RSA-static 3종이 통째로 사라졌습니다. 


결론: 인과관계 전체 그림

JDK 17.0.19 이전 (구 alpine)
  reactor-netty 기본 cipher 목록 = ECDHE 6종 + RSA-static 3종 + TLS1.3 2종
  → 외부 서비스가 선호하는 TLS_RSA_WITH_AES_128_GCM_SHA256로 정상 협상
JDK 17.0.19+10 (jammy)
  disabledAlgorithms가 RSA-static 3종을 enabledCipherSuites에서 제거
  → 남은 건 ECDHE + TLS1.3뿐인데 외부 서비스는 ECDHE, TLS1.3 를 전혀 지원하지 않음
  → handshake_failure (실제 장애)

지난 글에서 던졌던 "정말 TLS_RSA_* 하나가 원인의 전부였을까"라는 의문에 대한 답은 결국 맞았습니다, 그게 유일한 원인이었습니다. 다만 그 과정에서 아무도 몰랐던 사실 두 가지를 덤으로 얻을 수 있었습니다.

  1. SslContextBuilder를 직접 호출한 기본값과 HttpClient.create()가 알아서 쓰는 기본값은 서로 다른 cipher 목록을 갖는다 — 뭔가를 "명시적으로 설정"하는 순간, 의도치 않게 더 안전한(넓은) 기본값에서 이탈할 수 있다.
  2. JDK tls 정책을 우회하지 않고도 가능한 Cipher Suite가 존재했다.

후속 조치: JVM 플래그를 걷어내다

TLS_DHE_RSA_* 계열은 forward secrecy가 있어서 애초에 disabledAlgorithms 제약에 걸리지 않습니다. 따라서 cipher를 이걸로 명시하면 JVM 보안 정책을 건드릴 필요가 없습니다.

// Before
.ciphers(listOf("TLS_RSA_WITH_AES_128_GCM_SHA256"), SupportedCipherSuiteFilter.INSTANCE)
// After
.ciphers(
    listOf(
        "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256",
        "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384",
    ),
    SupportedCipherSuiteFilter.INSTANCE,
)

 

지난 글 마지막에 "이게 정말 원인의 전부인지는 좀 더 파봐야 할 것 같다"라고 적었던 게, 결과적으로 원래 진단이 맞았다는 걸 재확인하는 여정이 됐습니다. 다만 그 여정에서 실제 재현 테스트 코드와 프로덕션 코드가 서로 다른 경로를 타고 있었다는 걸 늦게 알아챈 게 가장 뼈아픈 삽질이었습니다 — 가설을 검증할 땐 반드시 실제 코드 경로를 그대로 태워봐야 한다는 걸 다시 한번 배울 수 있었습니다.

다음 편으로는 그렇다면 JDK 17.0.18부터 왜 TLS_RSA를 비활성화했을까? 에 대한 글을 이어서 써보도록 하겠습니다.

반응형