| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- dynamic query
- 자바
- JVM
- SQL
- MongoDB
- 유사코드
- 프로젝트
- 리눅스
- 초대장
- smart cast
- c#
- 문법 정리
- 파이썬
- hyperledger
- 백준 알고리즘
- 오라클
- spring
- gradle
- 티스토리
- resilience4j
- oracle
- 학점
- 자바 프로젝트
- auto configure
- jsp
- K6
- 운영체제
- 파이썬 소스
- 알고리즘
- 오라클 디비
- Today
- Total
모종닷컴
Docker 이미지 변경 후 TLS Handshake가 깨졌다 (2) — 정말 TLS_RSA_* 하나가 원인의 전부였을까? 본문
지난 글에서 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_* 하나가 원인의 전부였을까"라는 의문에 대한 답은 결국 맞았습니다, 그게 유일한 원인이었습니다. 다만 그 과정에서 아무도 몰랐던 사실 두 가지를 덤으로 얻을 수 있었습니다.
- SslContextBuilder를 직접 호출한 기본값과 HttpClient.create()가 알아서 쓰는 기본값은 서로 다른 cipher 목록을 갖는다 — 뭔가를 "명시적으로 설정"하는 순간, 의도치 않게 더 안전한(넓은) 기본값에서 이탈할 수 있다.
- 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를 비활성화했을까? 에 대한 글을 이어서 써보도록 하겠습니다.
