openssl/openssl

TLS 1.3 server silently accepts ClientHello missing required key_share / supported_groups extensions when PSK is not offered

Open

#31,229 创建于 2026年5月19日

在 GitHub 查看
 (1 评论) (0 反应) (0 负责人)C (30,157 star) (11,262 fork)batch import
branch: 3.1help wantedtriaged: bug

描述

Description

RFC 8446 Section 4.1.2 specifies that a TLS 1.3 ClientHello MUST contain either:

  • a non-empty key_share extension, or
  • a pre_shared_key extension with PSK key exchange modes that include psk_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-r1 on 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.

贡献者指南