Skip to content

Commit d9cbab9

Browse files
committed
crypto/ossl: prevent openssl.cnf loading and fix CONF module init
Prevent the default OpenSSL config from being loaded (OPENSSL_CONF env var or /etc/ssl/openssl.cnf file) as we want to control the initialization of OpenSSL and avoid any incompatibilities between the OS-provided openssl.cnf and our statically linked OpenSSL version. OpenSSL uses internal RUN_ONCE guards for initialization flags. The NO_* flags are sticky: once NO_LOAD_CONFIG wins, subsequent calls with LOAD_CONFIG are silently ignored. This is also why we can remove OPENSSL_INIT_NO_LOAD_CONFIG from OPENSSL_init_ssl — it is a flag for OPENSSL_init_crypto and has no effect once already set. Since NO_LOAD_CONFIG opts out of automatic config loading, the internal call to OPENSSL_load_builtin_modules that would normally happen during OPENSSL_config is skipped. Per the OPENSSL_load_builtin_modules(3) docs, applications that use configuration functions directly must call this before any other configuration code, so we call it explicitly early in start(). This must happen while the thread-local default context is still the global default: the CONF module list is protected by a global RCU lock (conf_mod.c) that is initialized exactly once via pthread_once. That initialization resolves a NULL OSSL_LIB_CTX* to the current thread-local default and permanently stores the pointer in the lock. Without this early call, the first OSSL_LIB_CTX_load_config (on the thread worker) would trigger the initialization after a custom context had been set as the thread-local default, causing the lock to capture a pointer to that context. Freeing the context later (e.g. between test runs) leaves the lock with a dangling pointer, resulting in a use-after-free on subsequent config loads. We also add a regression test that failed before this change. Ref: - https://docs.openssl.org/3.5/man3/OPENSSL_init_ssl/#description - https://docs.openssl.org/3.5/man3/OPENSSL_init_crypto/#description - https://docs.openssl.org/3.5/man3/OPENSSL_load_builtin_modules/#description
1 parent ac5996e commit d9cbab9

2 files changed

Lines changed: 262 additions & 3 deletions

File tree

src/v/crypto/ossl_context_service.cc

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,7 @@ initialize_result<initialize_return> initialize_openssl(
118118
vlog(lg.debug, "Set default properties to \"fips=yes\"");
119119
}
120120

121-
if (!OPENSSL_init_ssl(
122-
OPENSSL_INIT_LOAD_SSL_STRINGS | OPENSSL_INIT_NO_LOAD_CONFIG,
123-
nullptr)) {
121+
if (!OPENSSL_init_ssl(OPENSSL_INIT_LOAD_SSL_STRINGS, nullptr)) {
124122
return make_ssl_error_response("Failed to initialize OpenSSL");
125123
}
126124

@@ -189,6 +187,46 @@ class ossl_context_service::impl final {
189187

190188
ss::future<> start() {
191189
vlog(lg.debug, "Starting OpenSSL Context service...");
190+
191+
// Prevent the default OpenSSL config from being loaded (OPENSSL_CONF
192+
// env var or /etc/ssl/openssl.cnf file) as we want to control the
193+
// initialization of OpenSSL and avoid any incompatibilities between the
194+
// OS-provided openssl.cnf and our statically linked OpenSSL version.
195+
//
196+
// OpenSSL uses internal RUN_ONCE guards for initialization flags.
197+
// When OPENSSL_init_ssl/OPENSSL_init_crypto is called, each flag
198+
// (like LOAD_CONFIG vs NO_LOAD_CONFIG) races to be the first to set
199+
// its corresponding one-shot initializer. The "NO_*" flags are
200+
// sticky: once NO_LOAD_CONFIG wins, subsequent calls with LOAD_CONFIG
201+
// are silently ignored. By calling this early with NO_LOAD_CONFIG, we
202+
// ensure no other code path can accidentally trigger config loading
203+
// first.
204+
//
205+
// Note that most OpenSSL APIs (e.g. SSL_CTX_new) will trigger implicit
206+
// initialization if it hasn't already occurred, so it's important to
207+
// call this before any other OpenSSL usage.
208+
if (!OPENSSL_init_crypto(OPENSSL_INIT_NO_LOAD_CONFIG, nullptr)) {
209+
throw exception(make_ssl_error_response(
210+
"Failed to initialize OpenSSL with NO_LOAD_CONFIG"));
211+
}
212+
213+
// Per the OPENSSL_load_builtin_modules(3) docs: "Applications which
214+
// use the configuration functions directly will need to call
215+
// OPENSSL_load_builtin_modules() themselves before any other
216+
// configuration code." Since NO_LOAD_CONFIG above opts out of
217+
// automatic config loading (which would have called this
218+
// internally), and we later call OSSL_LIB_CTX_load_config directly,
219+
// we must call it ourselves.
220+
//
221+
// This also must happen while the thread-local default is still the
222+
// global default context: the CONF module list is protected by a
223+
// global RCU lock (conf_mod.c) initialized once via pthread_once
224+
// with ossl_rcu_lock_new(1, NULL), which resolves NULL to the
225+
// current thread-local default OSSL_LIB_CTX and permanently stores
226+
// the pointer. If a custom context were the default at that point,
227+
// the lock would hold a dangling pointer after that context is freed.
228+
OPENSSL_load_builtin_modules();
229+
192230
vassert(
193231
OSSL_LIB_CTX_get0_global_default()
194232
== OSSL_LIB_CTX_set0_default(nullptr),
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
# Copyright 2026 Redpanda Data, Inc.
2+
#
3+
# Use of this software is governed by the Business Source License
4+
# included in the file licenses/BSL.md
5+
#
6+
# As of the Change Date specified in that file, in accordance with
7+
# the Business Source License, use of this software will be governed
8+
# by the Apache License, Version 2.0
9+
10+
import socket
11+
import subprocess
12+
13+
from ducktape.cluster.cluster import ClusterNode
14+
from ducktape.services.service import Service
15+
from ducktape.tests.test import TestContext
16+
17+
from rptest.services.cluster import cluster
18+
from rptest.services.redpanda import (
19+
SecurityConfig,
20+
TLSProvider,
21+
)
22+
from rptest.services.tls import (
23+
Certificate,
24+
CertificateAuthority,
25+
TLSCertManager,
26+
)
27+
from rptest.tests.redpanda_test import RedpandaTest
28+
29+
30+
class OpenSSLConfigTestProvider(TLSProvider):
31+
"""Simple TLS provider for the OpenSSL config isolation test."""
32+
33+
def __init__(self, tls: TLSCertManager):
34+
self._tls = tls
35+
36+
@property
37+
def ca(self) -> CertificateAuthority:
38+
return self._tls.ca
39+
40+
def create_broker_cert(self, service: Service, node: ClusterNode) -> Certificate:
41+
assert node in service.nodes
42+
return self._tls.create_cert(node.name)
43+
44+
def create_service_client_cert(self, service: Service, name: str) -> Certificate:
45+
return self._tls.create_cert(socket.gethostname(), name=name)
46+
47+
48+
# OpenSSL config that restricts TLS to version 1.2 maximum
49+
# If this config is loaded by Redpanda, TLS 1.3 handshakes will fail
50+
RESTRICTIVE_OPENSSL_CNF = """
51+
# OpenSSL config that blocks TLS 1.3
52+
openssl_conf = openssl_init
53+
54+
[openssl_init]
55+
ssl_conf = ssl_sect
56+
57+
[ssl_sect]
58+
system_default = system_default_sect
59+
60+
[system_default_sect]
61+
# This setting would prevent TLS 1.3 connections if loaded
62+
MaxProtocol = TLSv1.2
63+
"""
64+
65+
66+
class OpenSSLConfigIsolationTest(RedpandaTest):
67+
"""
68+
Test that Redpanda does not load the system's openssl.cnf file.
69+
70+
This test verifies the fix for the OpenSSL initialization ordering bug
71+
where Redpanda could unintentionally pick up the OS's OPENSSL_CONF-configured
72+
openssl.cnf file, causing wrong crypto behavior.
73+
74+
The test works by:
75+
1. Creating a custom openssl.cnf that sets MaxProtocol = TLSv1.2
76+
2. Setting OPENSSL_CONF to point to this restrictive config
77+
3. Starting Redpanda with TLS enabled
78+
4. Attempting a TLS 1.3 handshake
79+
80+
If Redpanda incorrectly loads the system config, TLS 1.3 would be blocked
81+
and the handshake would fail. If Redpanda correctly ignores the system
82+
config (using OPENSSL_INIT_NO_LOAD_CONFIG), TLS 1.3 should succeed.
83+
"""
84+
85+
# Path on the node where we'll write the restrictive OpenSSL config
86+
RESTRICTIVE_CONFIG_PATH = "/tmp/restrictive_openssl.cnf"
87+
88+
def __init__(self, test_context: TestContext):
89+
super(OpenSSLConfigIsolationTest, self).__init__(test_context)
90+
self.security = SecurityConfig()
91+
self.tls = TLSCertManager(self.logger)
92+
93+
def setUp(self):
94+
# Configure TLS
95+
self.security.tls_provider = OpenSSLConfigTestProvider(tls=self.tls)
96+
self.redpanda.set_security_settings(self.security)
97+
98+
# Create a client certificate for testing TLS connections
99+
# The Kafka listener requires client certificate authentication
100+
self._client_cert = self.tls.create_cert(
101+
socket.gethostname(), name="test_client"
102+
)
103+
104+
# Write the restrictive OpenSSL config to each node
105+
for node in self.redpanda.nodes:
106+
node.account.create_file(
107+
self.RESTRICTIVE_CONFIG_PATH, RESTRICTIVE_OPENSSL_CNF
108+
)
109+
110+
# Set OPENSSL_CONF environment variable to point to the restrictive config
111+
# If Redpanda loads this config, TLS 1.3 will be blocked
112+
self.redpanda.set_environment({"OPENSSL_CONF": self.RESTRICTIVE_CONFIG_PATH})
113+
114+
# Start Redpanda with TLS enabled
115+
super().setUp()
116+
117+
def _verify_tls_version_works(
118+
self, node: ClusterNode, tls_version: str, port: int
119+
) -> bool:
120+
"""
121+
Attempt a TLS handshake with the specified version.
122+
123+
Returns True if the handshake succeeds, False if it fails.
124+
"""
125+
# Include client certificate since Kafka listener requires client auth
126+
cmd = (
127+
f"openssl s_client {tls_version} "
128+
f"-CAfile {self.tls.ca.crt} "
129+
f"-cert {self._client_cert.crt} "
130+
f"-key {self._client_cert.key} "
131+
f"-connect {node.name}:{port}"
132+
)
133+
self.logger.debug(f"Running: {cmd}")
134+
135+
try:
136+
output = subprocess.check_output(
137+
cmd.split(),
138+
stderr=subprocess.STDOUT,
139+
stdin=subprocess.DEVNULL,
140+
timeout=10,
141+
)
142+
output_str = output.decode()
143+
# Check for successful verification
144+
return (
145+
"Verify return code: 0" in output_str
146+
or "Verify return code: 19" in output_str
147+
)
148+
except subprocess.CalledProcessError as e:
149+
output_str = e.output.decode()
150+
self.logger.debug(f"TLS command output: {output_str}")
151+
152+
# Check for TLS version-specific errors FIRST - these indicate the
153+
# protocol version was rejected. Note: "Verify return code: 0" can
154+
# be misleading when the handshake fails before certificate exchange
155+
# (nothing to verify means verification "succeeds" vacuously)
156+
tls_version_errors = [
157+
"no protocols available",
158+
"tlsv1 alert protocol version",
159+
"wrong version number",
160+
"unsupported protocol",
161+
]
162+
if any(err in output_str for err in tls_version_errors):
163+
self.logger.debug("TLS version negotiation failed")
164+
return False
165+
166+
# Check that a cipher was actually negotiated (not "(NONE)")
167+
# If no cipher was negotiated, the TLS handshake failed
168+
if "Cipher is (NONE)" in output_str:
169+
self.logger.debug("TLS handshake failed - no cipher negotiated")
170+
return False
171+
172+
# Check for successful verification - openssl s_client may return
173+
# non-zero even when TLS handshake succeeded (e.g., server closes
174+
# connection after handshake)
175+
if (
176+
"Verify return code: 0" in output_str
177+
or "Verify return code: 19" in output_str
178+
):
179+
self.logger.debug("TLS verification succeeded")
180+
return True
181+
182+
# For other errors (like generic handshake failures), log and fail
183+
self.logger.debug("TLS handshake failed for non-version reason")
184+
return False
185+
except subprocess.TimeoutExpired:
186+
self.logger.error("TLS handshake timed out")
187+
return False
188+
189+
@cluster(num_nodes=1)
190+
def test_system_openssl_config_not_loaded(self):
191+
"""
192+
Verify that Redpanda ignores the system's OPENSSL_CONF setting.
193+
194+
This test sets OPENSSL_CONF to a config that blocks TLS 1.3, then
195+
verifies that TLS 1.3 handshakes still succeed. This proves Redpanda
196+
is using OPENSSL_INIT_NO_LOAD_CONFIG to prevent loading the system
197+
config.
198+
"""
199+
node = self.redpanda.nodes[0]
200+
kafka_port = 9092
201+
202+
# First verify TLS 1.2 works (baseline - should work regardless)
203+
self.logger.info("Verifying TLS 1.2 works (baseline check)")
204+
tls12_works = self._verify_tls_version_works(node, "-tls1_2", kafka_port)
205+
assert tls12_works, "TLS 1.2 should work - this is a baseline check"
206+
207+
# Now verify TLS 1.3 works - this is the actual test
208+
# If OPENSSL_CONF was loaded, TLS 1.3 would be blocked by MaxProtocol=TLSv1.2
209+
self.logger.info("Verifying TLS 1.3 works (proves system config not loaded)")
210+
tls13_works = self._verify_tls_version_works(node, "-tls1_3", kafka_port)
211+
212+
assert tls13_works, (
213+
"TLS 1.3 handshake failed! This suggests Redpanda loaded the "
214+
"system's OPENSSL_CONF which has MaxProtocol=TLSv1.2. "
215+
"Redpanda should use OPENSSL_INIT_NO_LOAD_CONFIG to prevent this."
216+
)
217+
218+
self.logger.info(
219+
"SUCCESS: TLS 1.3 works despite OPENSSL_CONF setting MaxProtocol=TLSv1.2. "
220+
"This proves Redpanda correctly ignores the system OpenSSL config."
221+
)

0 commit comments

Comments
 (0)