Skip to content

Unencrypted record-layer version is still checked instead of ignored #11080

Description

@LiD0209

Unencrypted record-layer version is still checked instead of ignored

Summary

This report covers the TLS 1.3 requirement to ignore the record-layer version on unencrypted records. After rechecking RFC 8446, re-reading the parser, and rerunning a live differential probe on August 3, 2026, the issue remains real on the client receive path: a valid HelloRetryRequest is accepted when the outer record version is 0x0303 or 0x0304, but the same message is rejected with VERSION_ERROR when only that outer record version is changed to 0x0301 or 0x0302.

Standard Requirement

  • Official standard link: https://www.rfc-editor.org/rfc/rfc8446.html
  • Primary normative text: Section 5.1 Record Layer
  • Supporting checklist text: Appendix C.3 Implementation Pitfalls
  • Supporting compatibility text: Appendix D Backward Compatibility
  • Related but distinct handshake checks: Section 4.1.2 Client Hello, Section 4.1.3 Server Hello, Section 4.1.4 Hello Retry Request

Relevant RFC 8446 text:

legacy_record_version:  MUST be set to 0x0303 for all records
generated by a TLS 1.3 implementation other than an initial
ClientHello ... This field is deprecated and MUST be ignored for all purposes.
-  Do you ignore the TLS record layer version number in all
   unencrypted TLS records (see Appendix D)?
Prior versions of TLS used the record layer version number
(TLSPlaintext.legacy_record_version and
TLSCiphertext.legacy_record_version) for various purposes.  As of
TLS 1.3, this field is deprecated.  The value of
TLSPlaintext.legacy_record_version MUST be ignored by all
implementations.

By contrast, the handshake-layer legacy_version fields in ClientHello, ServerHello, and HelloRetryRequest are still meaningful and still have to be checked as specified by Sections 4.1.2 through 4.1.4. The requirement here is therefore narrow but clear: a TLS 1.3 implementation may validate the handshake-layer legacy_version fields, but it must not reject an otherwise valid unencrypted TLS record solely because of the outer TLSPlaintext record header version bytes.

Relevant Source Code

The decisive parser logic is still in src/internal.c.

src/internal.c:12724-12770

/* catch version mismatch */
#ifndef WOLFSSL_TLS13
    if (rh->pvMajor != ssl->version.major || rh->pvMinor != ssl->version.minor)
#else
    if (rh->pvMajor != ssl->version.major ||
        (rh->pvMinor != ssl->version.minor &&
         (!IsAtLeastTLSv1_3(ssl->version) || rh->pvMinor != tls12minor)
        ))
#endif
    {
        if (ssl->options.side == WOLFSSL_SERVER_END &&
            ssl->options.acceptState < ACCEPT_FIRST_REPLY_DONE)

            WOLFSSL_MSG("Client attempting to connect with different version");
        else if (ssl->options.side == WOLFSSL_CLIENT_END &&
                                 ssl->options.downgrade &&
                                 ssl->options.connectState < FIRST_REPLY_DONE)
            WOLFSSL_MSG("Server attempting to accept with different version");
        ...
        else if (!(ssl->options.side == WOLFSSL_CLIENT_END &&
                            ssl->options.connectState == CLIENT_HELLO_SENT &&
                            rh->type == alert &&
                            rh->pvMajor == ssl->version.major &&
                            (rh->pvMinor < ssl->version.minor)
                   )) {
            WOLFSSL_MSG("SSL version error");
            WOLFSSL_ERROR_VERBOSE(VERSION_ERROR);
            return VERSION_ERROR;
        }
    }

On TLS 1.3 paths, GetRecordHeader() still treats the outer record header version as a gate. It accepts only the negotiated version or the special TLS 1.2 compatibility minor, and otherwise returns VERSION_ERROR. That is selective validation, not "ignore for all purposes".

Two details matter for reproducing the issue correctly:

  1. The server path has an early-handshake softening branch before ACCEPT_FIRST_REPLY_DONE, so a first-ClientHello server probe is not the cleanest differentiator.
  2. The TLS 1.3 client path reaches this record-header check before FIRST_REPLY_DONE, which makes HelloRetryRequest a clean place to test whether only the outer record version changes behavior.

For the client state transition used in the rerun, src/tls13.c moves the TLS 1.3 client from CLIENT_HELLO_SENT to FIRST_REPLY_DONE only after processing the first server flight.

Implementation Behavior

The implementation behavior is:

  1. Parse the outer record header into rh->pvMajor and rh->pvMinor.
  2. On TLS 1.3, accept only ssl->version.minor or TLSv1_2_MINOR.
  3. Otherwise return VERSION_ERROR, except for a few special cases such as early server-side handling and pre-negotiation alerts.

That means the record parser still branches on specific unencrypted record-layer version values instead of ignoring them and leaving version semantics to the handshake-layer fields.

Runtime Evidence

Focused rerun date: 2026-08-03

Round 1: existing family reproducer

The preexisting automated round still completed:

  • tls13_positive_control: passed
  • family_reproducer: passed

However, that family reproducer only emitted the structured status unencrypted_record_version_is_checked_not_ignored; it did not perform a live peer-side differential test that varied only the outer record-layer version while keeping the handshake-layer version fields valid. It was useful as a source-backed hint, but not the decisive runtime proof.

Round 2: live differential HelloRetryRequest probe

I reran the requirement family with a focused live probe against the TLS 1.3 client path.

Setup:

  1. Start client.exe -v 4 -d -J -h 127.0.0.1 -p <port>.
  2. Capture the client's first ClientHello.
  3. Synthesize a valid HelloRetryRequest that preserves the correct handshake-layer fields:
    • ServerHello/HelloRetryRequest.legacy_version = 0x0303
    • echoed session ID from the client's first ClientHello
    • one cipher suite that the client actually offered
    • supported_versions = 0x0304
    • one key-share group that the client actually advertised
  4. Change only the outer TLSPlaintext record header version.
  5. Observe whether the client sends a second ClientHello or immediately sends a fatal alert.

Observed results:

Outer record version Handshake legacy_version Observed behavior
0x0303 0x0303 Accepted the HelloRetryRequest and sent a second ClientHello
0x0304 0x0303 Accepted the HelloRetryRequest and sent a second ClientHello
0x0301 0x0303 Rejected it, sent fatal alert 15 03 03 00 02 02 46, and logged wolfSSL_connect error -326, record layer version error
0x0302 0x0303 Rejected it, sent fatal alert 15 03 03 00 02 02 46, and logged wolfSSL_connect error -326, record layer version error

Acceptance note:

For the accepted cases, the harness intentionally stopped after observing the second ClientHello, so the client later reported a socket shutdown rather than a completed handshake. That does not weaken the differential result: the acceptance criterion was whether the client advanced to the second ClientHello, and the rejection criterion was whether it immediately sent a fatal alert with VERSION_ERROR.

Why this is decisive:

The handshake-layer legacy_version stayed correct at 0x0303 in every case. The session ID, offered cipher, supported_versions, and selected key-share group were also kept consistent. Only the outer unencrypted record header version changed, and that alone flipped the result from "continue handshake" to "fatal alert". That is direct runtime evidence that the implementation is still checking the unencrypted record-layer version instead of ignoring it.

Inconsistency Reason

RFC 8446 requires the outer TLSPlaintext.legacy_record_version field to be ignored on unencrypted TLS 1.3 records, while wolfSSL's GetRecordHeader() still treats that field as a validity condition. Because a valid HelloRetryRequest is rejected when only the outer record version changes to other TLS 1.x values, the implementation does not satisfy the standard's ignore rule.

Decision Reason

This is a real issue. The standard text is explicit, the parser still returns VERSION_ERROR from the record-header version check, and the live differential rerun shows that changing only the outer unencrypted record version is enough to make the TLS 1.3 client reject an otherwise valid HelloRetryRequest.

Fix Direction

The fix should make TLS 1.3 unencrypted record processing ignore TLSPlaintext.legacy_record_version on receipt and leave version validation to the handshake-layer fields that RFC 8446 still defines:

  • keep validating ClientHello.legacy_version, ServerHello.legacy_version, and HelloRetryRequest fields as required by Sections 4.1.2 through 4.1.4;
  • stop rejecting an unencrypted TLS 1.3 record only because its outer TLSPlaintext.legacy_record_version is not one of the parser's currently preferred values;
  • add a regression test that repeats the differential probe above and proves the same valid HelloRetryRequest is processed identically across alternate outer record-header versions.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions