From 74cd9f9f8cc86134555fa1b4c3ebcec8f8872476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Ondr=C3=A1=C4=8Dek?= Date: Sat, 31 Oct 2026 21:30:04 +0100 Subject: [PATCH 1/2] Fix GH-25088: SNI server contexts do not inherit the base policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SNI callback switches the handshake to the per-host SSL_CTX, from which OpenSSL takes the cipher list, ALPN callback, DH params and security level. Apply the ciphers, alpn_protocols, dh_param and security_level options to the per-host contexts too. Fixes GH-25088 Signed-off-by: Roman Ondráček --- NEWS | 2 + ext/openssl/tests/gh25088.phpt | 98 +++++++++++++++++++ ext/openssl/tests/gh25088_dh.pem | 8 ++ ext/openssl/tests/gh25088_security_level.phpt | 79 +++++++++++++++ ext/openssl/xp_ssl.c | 81 ++++++++++++--- 5 files changed, 254 insertions(+), 14 deletions(-) create mode 100644 ext/openssl/tests/gh25088.phpt create mode 100644 ext/openssl/tests/gh25088_dh.pem create mode 100644 ext/openssl/tests/gh25088_security_level.phpt diff --git a/NEWS b/NEWS index 82567c5abf39..99a12fb90b9a 100644 --- a/NEWS +++ b/NEWS @@ -335,6 +335,8 @@ PHP NEWS (Sam James, Weilin Du, Shivam Mathur) . Fixed bug GH-24784 (openssl_pkcs7_read() and openssl_cms_read() overwrite certificates with CRLs). (Alexander Danilov) + . Fixed bug GH-25088 (SNI server contexts do not inherit ciphers, ALPN, + DH params and security level). (Roman Ondráček) - PDO: . Fixed invalid ATTR_ORACLE_NULLS values being accepted and truncated. diff --git a/ext/openssl/tests/gh25088.phpt b/ext/openssl/tests/gh25088.phpt new file mode 100644 index 000000000000..d8f110bf929d --- /dev/null +++ b/ext/openssl/tests/gh25088.phpt @@ -0,0 +1,98 @@ +--TEST-- +GH-25088: SNI server contexts do not inherit ciphers, ALPN and DH params +--EXTENSIONS-- +openssl +--SKIPIF-- + +--FILE-- +saveNewCertAndKey('base.php.net', $baseCertFile, $baseKeyFile); +$certificateGenerator->saveNewCertAndKey('sni.php.net', $sniCertFile, $sniKeyFile); + +$serverCode = <<<'CODE' + $flags = STREAM_SERVER_BIND|STREAM_SERVER_LISTEN; + $ctx = stream_context_create(['ssl' => [ + 'local_cert' => '%s', + 'local_pk' => '%s', + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER, + 'ciphers' => 'ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384', + 'alpn_protocols' => 'h2,http/1.1', + 'dh_param' => '%s', + 'SNI_server_certs' => [ + 'sni.php.net' => [ + 'local_cert' => '%s', + 'local_pk' => '%s', + ], + ], + ]]); + + $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr, $flags, $ctx); + phpt_notify_server_start($server); + + for ($i = 0; $i < 8; $i++) { + $conn = @stream_socket_accept($server, 3); + if ($conn) { + fclose($conn); + } + } +CODE; +$serverCode = sprintf($serverCode, $baseCertFile, $baseKeyFile, __DIR__ . DIRECTORY_SEPARATOR . 'gh25088_dh.pem', $sniCertFile, $sniKeyFile); + +$clientCode = <<<'CODE' + function connect(string $peerName, array $opts) { + $ctx = stream_context_create(['ssl' => $opts + [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'peer_name' => $peerName, + 'capture_peer_cert' => true, + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, + ]]); + $client = @stream_socket_client("tls://{{ ADDR }}", $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $ctx); + if (!$client) { + return 'handshake failed'; + } + $cn = openssl_x509_parse(stream_context_get_options($ctx)['ssl']['peer_certificate'])['subject']['CN']; + $crypto = stream_get_meta_data($client)['crypto']; + return sprintf('%s %s ALPN=%s', $cn, $crypto['cipher_name'], $crypto['alpn_protocol'] ?? 'none'); + } + + foreach (['base.php.net', 'sni.php.net'] as $peerName) { + echo "$peerName:\n"; + echo " disallowed cipher: ", connect($peerName, ['ciphers' => 'ECDHE-RSA-AES128-GCM-SHA256']), "\n"; + echo " allowed cipher: ", connect($peerName, ['ciphers' => 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384']), "\n"; + echo " DHE cipher: ", connect($peerName, ['ciphers' => 'DHE-RSA-AES256-GCM-SHA384']), "\n"; + echo " ALPN: ", connect($peerName, ['alpn_protocols' => 'h2']), "\n"; + } +CODE; + +include 'ServerClientTestCase.inc'; +ServerClientTestCase::getInstance()->run($clientCode, $serverCode); +?> +--CLEAN-- + +--EXPECT-- +base.php.net: + disallowed cipher: handshake failed + allowed cipher: base.php.net ECDHE-RSA-AES256-GCM-SHA384 ALPN=none + DHE cipher: base.php.net DHE-RSA-AES256-GCM-SHA384 ALPN=none + ALPN: base.php.net ECDHE-RSA-AES256-GCM-SHA384 ALPN=h2 +sni.php.net: + disallowed cipher: handshake failed + allowed cipher: sni.php.net ECDHE-RSA-AES256-GCM-SHA384 ALPN=none + DHE cipher: sni.php.net DHE-RSA-AES256-GCM-SHA384 ALPN=none + ALPN: sni.php.net ECDHE-RSA-AES256-GCM-SHA384 ALPN=h2 diff --git a/ext/openssl/tests/gh25088_dh.pem b/ext/openssl/tests/gh25088_dh.pem new file mode 100644 index 000000000000..9b182b7201fd --- /dev/null +++ b/ext/openssl/tests/gh25088_dh.pem @@ -0,0 +1,8 @@ +-----BEGIN DH PARAMETERS----- +MIIBCAKCAQEA//////////+t+FRYortKmq/cViAnPTzx2LnFg84tNpWp4TZBFGQz ++8yTnc4kmz75fS/jY2MMddj2gbICrsRhetPfHtXV/WVhJDP1H18GbtCFY2VVPe0a +87VXE15/V8k1mE8McODmi3fipona8+/och3xWKE2rec1MKzKT0g6eXq8CrGCsyT7 +YdEIqUuyyOP7uWrat2DX9GgdT0Kj3jlN9K5W7edjcrsZCwenyO4KbXCeAvzhzffi +7MA0BM0oNC9hkXL+nOmFg/+OTxIy7vKBg8P+OxtMb61zO7X8vC7CIAXFjvGDfRaD +ssbzSibBsu/6iGtCOGEoXJf//////////wIBAg== +-----END DH PARAMETERS----- diff --git a/ext/openssl/tests/gh25088_security_level.phpt b/ext/openssl/tests/gh25088_security_level.phpt new file mode 100644 index 000000000000..51249dc4a802 --- /dev/null +++ b/ext/openssl/tests/gh25088_security_level.phpt @@ -0,0 +1,79 @@ +--TEST-- +GH-25088: SNI server contexts inherit the security level +--EXTENSIONS-- +openssl +--SKIPIF-- + +--FILE-- +saveNewCertAndKey('base.php.net', $baseCertFile, $baseKeyFile); +// A 768-bit key is refused at security level 1 and above. +$certificateGenerator->saveNewCertAndKey('sni.php.net', $sniCertFile, $sniKeyFile, 768); + +$serverCode = <<<'CODE' + $flags = STREAM_SERVER_BIND|STREAM_SERVER_LISTEN; + $ctx = stream_context_create(['ssl' => [ + 'local_cert' => '%s', + 'local_pk' => '%s', + 'security_level' => 0, + 'SNI_server_certs' => [ + 'sni.php.net' => [ + 'local_cert' => '%s', + 'local_pk' => '%s', + ], + ], + ]]); + + $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr, $flags, $ctx); + phpt_notify_server_start($server); + + for ($i = 0; $i < 2; $i++) { + $conn = @stream_socket_accept($server, 3); + if ($conn) { + fclose($conn); + } + } +CODE; +$serverCode = sprintf($serverCode, $baseCertFile, $baseKeyFile, $sniCertFile, $sniKeyFile); + +$clientCode = <<<'CODE' + foreach (['base.php.net', 'sni.php.net'] as $peerName) { + $ctx = stream_context_create(['ssl' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'peer_name' => $peerName, + 'capture_peer_cert' => true, + 'security_level' => 0, + ]]); + $client = @stream_socket_client("tls://{{ ADDR }}", $errno, $errstr, 5, STREAM_CLIENT_CONNECT, $ctx); + if (!$client) { + echo "$peerName: handshake failed\n"; + continue; + } + $cert = openssl_x509_parse(stream_context_get_options($ctx)['ssl']['peer_certificate']); + echo "$peerName: ", $cert['subject']['CN'], "\n"; + } +CODE; + +include 'ServerClientTestCase.inc'; +ServerClientTestCase::getInstance()->run($clientCode, $serverCode); +?> +--CLEAN-- + +--EXPECT-- +base.php.net: base.php.net +sni.php.net: sni.php.net diff --git a/ext/openssl/xp_ssl.c b/ext/openssl/xp_ssl.c index 9ae5105fea10..6ec34f47483e 100644 --- a/ext/openssl/xp_ssl.c +++ b/ext/openssl/xp_ssl.c @@ -1429,6 +1429,31 @@ static zend_result php_openssl_set_server_specific_opts(php_stream *stream, SSL_ } /* }}} */ +static zend_result php_openssl_set_cipher_list(php_stream *stream, SSL_CTX *ctx) /* {{{ */ +{ + zval *val; + char *cipherlist = NULL; + + GET_VER_OPT_STRING("ciphers", cipherlist); +#ifndef USE_OPENSSL_SYSTEM_CIPHERS + if (!cipherlist) { + cipherlist = OPENSSL_DEFAULT_STREAM_CIPHERS; + } +#endif + if (cipherlist && SSL_CTX_set_cipher_list(ctx, cipherlist) != 1) { + return FAILURE; + } + + return SUCCESS; +} +/* }}} */ + +#ifdef HAVE_TLS_ALPN +static int php_openssl_server_alpn_callback(SSL *ssl_handle, + const unsigned char **out, unsigned char *outlen, + const unsigned char *in, unsigned int inlen, void *arg); +#endif + #ifdef HAVE_TLS_SNI static int php_openssl_server_sni_callback(SSL *ssl_handle, int *al, void *arg) /* {{{ */ { @@ -1461,7 +1486,37 @@ static int php_openssl_server_sni_callback(SSL *ssl_handle, int *al, void *arg) } /* }}} */ -static SSL_CTX *php_openssl_create_sni_server_ctx(char *cert_path, char *key_path) /* {{{ */ +/* SSL_set_SSL_CTX() in the SNI callback switches the handshake to the per-host + * context, so the cipher list, ALPN selection callback, DH parameters and + * security level are taken from that context instead of the base one. Apply + * the base context policy to the per-host context. */ +static zend_result php_openssl_apply_server_policy_to_sni_ctx(php_stream *stream, + php_openssl_netstream_data_t *sslsock, SSL_CTX *ctx) /* {{{ */ +{ + if (FAILURE == php_openssl_set_cipher_list(stream, ctx)) { + return FAILURE; + } + +#ifdef HAVE_SEC_LEVEL + SSL_CTX_set_security_level(ctx, SSL_CTX_get_security_level(sslsock->ctx)); +#endif + + if (FAILURE == php_openssl_set_server_dh_param(stream, ctx)) { + return FAILURE; + } + +#ifdef HAVE_TLS_ALPN + if (sslsock->alpn_ctx.data) { + SSL_CTX_set_alpn_select_cb(ctx, php_openssl_server_alpn_callback, sslsock); + } +#endif + + return SUCCESS; +} +/* }}} */ + +static SSL_CTX *php_openssl_create_sni_server_ctx(php_stream *stream, + php_openssl_netstream_data_t *sslsock, char *cert_path, char *key_path) /* {{{ */ { /* The hello method is not inherited by SSL structs when assigning a new context * inside the SNI callback, so the just use SSLv23 */ @@ -1471,6 +1526,13 @@ static SSL_CTX *php_openssl_create_sni_server_ctx(char *cert_path, char *key_pat return NULL; } + /* The security level must be set before loading the certificate and key, + * as OpenSSL checks them against it. */ + if (FAILURE == php_openssl_apply_server_policy_to_sni_ctx(stream, sslsock, ctx)) { + SSL_CTX_free(ctx); + return NULL; + } + if (SSL_CTX_use_certificate_chain_file(ctx, cert_path) != 1) { php_error_docref(NULL, E_WARNING, "Failed setting local cert chain file `%s'; " \ @@ -1599,10 +1661,10 @@ static zend_result php_openssl_enable_server_sni( } zend_string_release(local_pk_str); - ctx = php_openssl_create_sni_server_ctx(resolved_cert_path_buff, resolved_pk_path_buff); + ctx = php_openssl_create_sni_server_ctx(stream, sslsock, resolved_cert_path_buff, resolved_pk_path_buff); } else if (Z_TYPE_P(current) == IS_STRING) { if (php_openssl_check_path_str_ex(Z_STR_P(current), resolved_path_buff, 0, false, false, "SNI_server_certs in ssl stream context")) { - ctx = php_openssl_create_sni_server_ctx(resolved_path_buff, resolved_path_buff); + ctx = php_openssl_create_sni_server_ctx(stream, sslsock, resolved_path_buff, resolved_path_buff); } else { php_error_docref(NULL, E_WARNING, "Failed setting local cert chain file `%s'; file not found", @@ -1721,7 +1783,6 @@ static zend_result php_openssl_setup_crypto(php_stream *stream, int method_flags; zend_long min_version = 0; zend_long max_version = 0; - char *cipherlist = NULL; char *alpn_protocols = NULL; zval *val; bool verify_peer = false; @@ -1789,16 +1850,8 @@ static zend_result php_openssl_setup_crypto(php_stream *stream, SSL_CTX_set_default_passwd_cb(sslsock->ctx, php_openssl_passwd_callback); } - GET_VER_OPT_STRING("ciphers", cipherlist); -#ifndef USE_OPENSSL_SYSTEM_CIPHERS - if (!cipherlist) { - cipherlist = OPENSSL_DEFAULT_STREAM_CIPHERS; - } -#endif - if (cipherlist) { - if (SSL_CTX_set_cipher_list(sslsock->ctx, cipherlist) != 1) { - return FAILURE; - } + if (FAILURE == php_openssl_set_cipher_list(stream, sslsock->ctx)) { + return FAILURE; } if (GET_VER_OPT("security_level")) { From e7c84f83cfbf0d9719ee3fffcf8fd5e031e7ee96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Ondr=C3=A1=C4=8Dek?= Date: Wed, 11 Nov 2026 20:05:45 +0100 Subject: [PATCH 2/2] Fix issues identified in bukka's review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Roman Ondráček --- ext/openssl/tests/gh25088.phpt | 1 - ext/openssl/xp_ssl.c | 18 +++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/ext/openssl/tests/gh25088.phpt b/ext/openssl/tests/gh25088.phpt index d8f110bf929d..6b355944f44b 100644 --- a/ext/openssl/tests/gh25088.phpt +++ b/ext/openssl/tests/gh25088.phpt @@ -5,7 +5,6 @@ openssl --SKIPIF-- --FILE--