TLS 1.3 server silently accepts ClientHello missing required key_share / supported_groups extensions when PSK is not offered
#31,229 创建于 2026年5月19日
描述
Description
RFC 8446 Section 4.1.2 specifies that a TLS 1.3 ClientHello MUST contain either:
- a non-empty
key_shareextension, or - a
pre_shared_keyextension with PSK key exchange modes that includepsk_dhe_ke
When a ClientHello arrives with supported_versions=0x0304 but without a
key_share extension and without any pre_shared_key extension, the server
must respond with a fatal alert (or at minimum a HelloRetryRequest). It must
not keep the socket open without responding.
OpenSSL 3.1.8 appears to violate this requirement under a specific parameter combination: when the ClientHello is fragmented across many small TLS records (RECORD_LENGTH=50) and contains several optional extensions, the server keeps the socket open indefinitely without sending any alert.
The same applies to supported_groups: RFC 8446 requires the extension
whenever (EC)DHE is in use. OpenSSL's check for a missing supported_groups
is embedded inside tls_parse_ctos_key_share(), so if key_share is absent
the supported_groups check is never reached either. This is also reproducible
with the same fragmentation approach.
Affected Versions
- Confirmed: OpenSSL 3.1.8 (
openssl-3.1.8-r1on alpine:3.19)
Steps to Reproduce
# Terminal 1: TLS 1.3-only server, no PSK configured
openssl req -x509 -newkey rsa:2048 -keyout /tmp/srv.key -out /tmp/srv.crt \
-days 1 -nodes -subj "/CN=test"
openssl s_server -tls1_3 -accept 0.0.0.0:4433 -key /tmp/srv.key -cert /tmp/srv.crt
# Terminal 2: repro_missing_keyshare.py (requires: python3)
# Tests two cases identified from tls-anvil exhaustive results:
# omitKeyShare: no key_share + no supported_groups, RECORD_LENGTH=50
# omitSupportedGroups: key_share present (FFDHE2048) but no supported_groups, RECORD_LENGTH=50
import socket, struct, os, time, select
def u16(n): return struct.pack('>H', n)
def u8(n): return struct.pack('B', n)
GREASE = b'\x0a\x0a'
def send_and_check(handshake, record_len, label):
wire = b''
for i in range(0, len(handshake), record_len):
c = handshake[i:i+record_len]
wire += b'\x16\x03\x01' + u16(len(c)) + c
wire += b'\x14\x03\x03\x00\x01\x01' # compat CCS
s = socket.create_connection(('127.0.0.1', 4433))
s.sendall(wire)
chunks = b''
deadline = time.time() + 4
while time.time() < deadline:
r, _, _ = select.select([s], [], [], 0.2)
if r:
try:
d = s.recv(4096)
if not d: break
chunks += d
if chunks[0] == 0x15: break
except OSError: break
s.close()
if not chunks:
print(f"{label}: FAIL -- socket stayed open, no alert (RFC 8446 violated)")
elif chunks[0] == 0x15 and len(chunks) >= 7:
lvl, desc = chunks[5], chunks[6]
ok = lvl == 2 and desc in (40, 47, 109)
print(f"{label}: {'PASS' if ok else 'UNEXPECTED'} -- Alert level={lvl} desc={desc}")
else:
print(f"{label}: OTHER -- {chunks[:8].hex()}")
def build_exts(include_ks=True, include_sg=True, named_grp=b'\x00\x1d', pubkey=None):
e = b''
e += struct.pack('>HHB', 0x002b, 3, 2) + b'\x03\x04' # supported_versions
if include_sg:
grps = GREASE + named_grp + b'\x00\x1d\x00\x17'
e += struct.pack('>HH', 0x000a, 2+len(grps)) + u16(len(grps)) + grps
if include_ks:
pk = pubkey or os.urandom(32)
ks = named_grp + u16(len(pk)) + pk
e += struct.pack('>HH', 0x0033, 2+len(ks)) + u16(len(ks)) + ks
sig = GREASE + b'\x04\x03\x05\x03\x06\x03\x08\x07\x08\x04\x01\x05\x01\x06\x01'
e += struct.pack('>HH', 0x000d, 2+len(sig)) + u16(len(sig)) + sig
e += struct.pack('>HHB', 0x002d, 2, 1) + b'\x01'
e += struct.pack('>HH', 0x0017, 0)
e += struct.pack('>HH', 0x0023, 0)
e += struct.pack('>HHB', 0xff01, 1, 0)
alpn = b'\x08http/1.1'
e += struct.pack('>HH', 0x0010, 4+len(alpn)) + u16(2+len(alpn)) + u16(len(alpn)) + alpn
e += struct.pack('>HHB', 0x000f, 1, 1)
e += struct.pack('>HHB', 0x0001, 1, 2)
pad = max(0, 500 - len(e) - 4)
e += struct.pack('>HH', 0x0015, pad) + b'\x00'*pad
return e
def build_hello(exts):
r, sid = os.urandom(32), os.urandom(32)
cs = GREASE + b'\x13\x01'
body = b'\x03\x03' + r + u8(32) + sid + u16(len(cs)) + cs + b'\x01\x00' + u16(len(exts)) + exts
return b'\x01' + struct.pack('>I', len(body))[1:] + body
# Case 1: omitKeyShare -- no key_share, no supported_groups
send_and_check(build_hello(build_exts(include_ks=False, include_sg=False)), 50, "omitKeyShare")
# Case 2: omitSupportedGroups -- FFDHE2048 key_share present, no supported_groups
send_and_check(build_hello(build_exts(include_ks=True, include_sg=False,
named_grp=b'\x01\x00', pubkey=os.urandom(256))), 50, "omitSupportedGroups")
Expected:
omitKeyShare: PASS -- Alert level=2 desc=109
omitSupportedGroups: PASS -- Alert level=2 desc=109
Actual (OpenSSL 3.1.8, s_server -tls1_3):
omitKeyShare: FAIL -- socket stayed open, no alert (RFC 8446 violated)
omitSupportedGroups: FAIL -- socket stayed open, no alert (RFC 8446 violated)
Reproduction with tls-anvil (exhaustive)
tls-anvil against
openssl s_server -tls1_3 (no PSK configured, strength=1):
Test: TLS13.ClientHello.omitKeyShare
Result: PARTIALLY_FAILED
6 out of 22 parameter combinations -> socket still open, no alert
16/22 -> correct fatal alert
Test: TLS13.ClientHello.omitSupportedGroups
Result: PARTIALLY_FAILED
10 out of 25 parameter combinations -> socket still open, no alert
15/25 -> correct fatal alert
Failure message (both tests):
"Expected a fatal alert but no messages have been received and socket is still open"
Failing combination examples:
- CIPHER=TLS_AES_128_GCM_SHA256, RECORD_LENGTH=50, all optional exts enabled
- CIPHER=TLS_AES_256_GCM_SHA384, RECORD_LENGTH=111, all optional exts enabled
- CIPHER=TLS_CHACHA20_POLY1305_SHA256, RECORD_LENGTH=1
Root Cause (Hypothesis)
omitKeyShare
The final validation is in ssl/statem/extensions.c, final_key_share():
/* server side, key_share extension was not sent by client */
if (s->server && !sent) {
if ((s->ext.psk_kex_mode & TLSEXT_KEX_MODE_FLAG_KE) != 0) {
/* PSK-only mode: key_share is optional -- skip further checks */
return 1;
}
/* falls through to HRR logic, which does not always send a fatal alert */
}
The hypothesis is that when no pre_shared_key extension is present,
psk_kex_mode is initialised to a default that includes
TLSEXT_KEX_MODE_FLAG_KE. The PSK-only bypass is therefore taken even though
no PSK was actually offered. For specific parameter combinations (notably when
the ClientHello is heavily fragmented) the server then neither sends HRR nor a
fatal alert, leaving the socket open.
omitSupportedGroups
The supported_groups presence check for TLS 1.3 lives inside
tls_parse_ctos_key_share() in ssl/statem/extensions_srvr.c (~line 870):
if (clnt_num_groups == 0) {
SSLfatal(s, SSL_AD_MISSING_EXTENSION,
SSL_R_MISSING_SUPPORTED_GROUPS_EXTENSION);
return 0;
}
This function is only invoked when the key_share extension is present. When
key_share is absent, tls_parse_ctos_key_share() is never called and the
supported_groups absence goes undetected. There does not appear to be an
independent supported_groups presence check for the TLS 1.3 server path.
This analysis is based on code inspection and tls-anvil results; the exact failing code path was not confirmed with a debugger trace.