Skip to content

[Bug]: RFC 8446 violation: wolfSSL sends a decode_error syntactically valid TLS 1.2 ServerHello #10761

Description

@aeyno

Version

wolfssl 5.9.1

Description

When a wolfSSL TLS 1.3 client receives a ServerHello with legacy_version = 0x0303 (TLS 1.2)
and no supported_versions extension, it sends a decode_error alert (code 50) and closes the
connection.

The correct alert per RFC 8446 §6.2 is protocol_version (code 70): the server offered a
version the TLS 1.3-only client does not support, which is a version-negotiation failure, not a
decode error, the message is correctly encoded. OpenSSL 3.4.0 sends protocol_version for the same input.

Impact

RFC violation.

RFC 8446 violation

RFC 8446 §6.2 defines:

"decode_error: A message could not be decoded because some field was
out of the specified range or the length of the message was
incorrect. This alert is used for errors where the message does
not conform to the formal protocol syntax."

The message is syntactically valid per the RFC so there should not be a decode error.

Reproduction steps

Craft a TLS 1.2 ServerHello with no extensions:

  • Record Layer:
    • ContentType: Handshake (22)
    • Version: TLS 1.2 legacy marker (0x0303)
  • Handshake:
    • Type: ServerHello (2)
    • legacy_version: 0x0303
    • Random: 32 arbitrary bytes (not the HRR magic constant)
    • SessionID: echoed from the ClientHello
    • CipherSuite: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (0xC02B)
    • Compression: null (0x00)
    • Extensions: (absent — no extensions length field, no extensions data)

Start the following Python TCP server, which parses the session ID from the client's
ClientHello and echoes it in the crafted ServerHello:

import socket
import struct

HOST = "0.0.0.0"
PORT = 3000

ALERT_DESC = {
    0: "close_notify", 10: "unexpected_message", 40: "handshake_failure",
    47: "illegal_parameter", 50: "decode_error", 70: "protocol_version",
    109: "missing_extension",
}


def extract_session_id(ch: bytes) -> bytes:
    """Return the session_id bytes from a raw TLS ClientHello record."""
    # Record (5) + Handshake header (4) + legacy_version (2) + random (32) = 43
    off = 43
    if len(ch) <= off:
        return b""
    sid_len = ch[off]
    return ch[off + 1 : off + 1 + sid_len]


def make_server_hello(session_id: bytes) -> bytes:
    """
    Build a TLS 1.2 ServerHello with no extensions.
    A TLS 1.3-only client MUST reject this with a protocol_version alert (RFC 8446 §6.2).
    """
    rand  = bytes([0x42] * 32)              # arbitrary random, not HRR magic
    body  = b"\x03\x03"                     # legacy_version = TLS 1.2
    body += rand
    body += bytes([len(session_id)]) + session_id
    body += b"\xc0\x2b"                     # TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
    body += b"\x00"                         # compression = null
    # No extensions field: TLS 1.2 ServerHello without extensions is valid on-the-wire.
    hs = b"\x02" + struct.pack(">I", len(body))[1:] + body
    return b"\x16\x03\x03" + struct.pack(">H", len(hs)) + hs


def parse_alerts(data: bytes) -> list[str]:
    msgs = []
    i = 0
    while i + 5 <= len(data):
        rec_t = data[i]
        rec_l = struct.unpack(">H", data[i + 3 : i + 5])[0]
        body  = data[i + 5 : i + 5 + rec_l]
        i    += 5 + rec_l
        if rec_t == 0x15 and len(body) >= 2:
            level = "fatal" if body[0] == 2 else "warning"
            desc  = ALERT_DESC.get(body[1], body[1])
            msgs.append(f"Alert({level},{desc})")
    return msgs


with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind((HOST, PORT))
    srv.listen(1)
    print(f"[*] Listening on {HOST}:{PORT} ...")

    conn, addr = srv.accept()
    with conn:
        print(f"[+] Connection from {addr}")
        ch = conn.recv(4096)
        print(f"[>] Received ClientHello ({len(ch)} bytes)")

        sid = extract_session_id(ch)
        sh  = make_server_hello(sid)
        conn.sendall(sh)
        print(f"[<] Sent TLS 1.2 ServerHello (no extensions): {sh.hex()}")

        conn.settimeout(3)
        data = b""
        try:
            while True:
                chunk = conn.recv(4096)
                if not chunk:
                    break
                data += chunk
        except socket.timeout:
            pass

        alerts = parse_alerts(data)
        result = ", ".join(alerts) if alerts else f"raw: {data[:20].hex()}"
        print(f"[>] Client response: {result}")

Then initiate a TLS 1.3-only handshake with a wolfSSL client:

./build/examples/client/client -p 3000 -v 4

Acknowledgements

This bug was found thanks to the tlspuffin fuzzer designed and developed by the tlspuffin team:

  • Max Ammann
  • Olivier Demengeon - Loria, Inria
  • Tom Gouville - Loria, Inria
  • Lucca Hirschi - Loria, Inria
  • Steve Kremer - Loria, Inria
  • Michael Mera - Loria, Inria

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions