Skip to content

Fatal-alert closure does not stop later application writes #11073

Description

@LiD0209

Fatal-alert closure does not stop later application writes

Problem Description

Static triage initially found a plausible mismatch in wolfSSL's handling of the requirement to close the connection without sending additional data. A focused rerun on August 3, 2026 confirmed the bug: after wolfSSL sends a fatal alert, a later wolfSSL_write() can still build and queue a new application-data record on the same connection.

This report deduplicates multiple candidate-level records that resolved to the same root cause.

Standard Requirement

  • Official standard: RFC 8446
  • Section: Section 6.2 Error Alerts (lines 4880-4882)

Whenever an implementation encounters a fatal error condition, it
SHOULD send an appropriate fatal alert and MUST close the connection
without sending or receiving any additional data. In the rest of

Interpretation:

Applies whenever a fatal error condition is encountered. The implementation must close the connection and must not send any additional data after that point.

Relevant Source Code

The key inconsistency is not alert selection. The problem is that the fatal-alert send path marks the connection closed, but the later application-write path does not reject that closed state before building a new record.

src/internal.c:28360-28463

static int SendAlert_ex(WOLFSSL* ssl, int severity, int type)
{
    byte input[ALERT_SIZE];
    byte *output;
    int  sendSz;
    int  ret;
    int  outputSz;
    int  dtlsExtra = 0;
    const char* alert_str = NULL;

    WOLFSSL_ENTER("SendAlert");

    alert_str = AlertTypeToString(type);
    if (alert_str != NULL)
    {
        WOLFSSL_MSG_EX("SendAlert: %d %s", type, alert_str);
    }
    else
    {
        WOLFSSL_MSG_EX("SendAlert: %d", type);
    }

#ifdef WOLFSSL_QUIC
    if (WOLFSSL_IS_QUIC(ssl)) {
        ret = !ssl->quic.method->send_alert(ssl, ssl->quic.enc_level_write, (uint8_t)type);
        if (ret) {
            WOLFSSL_MSG("QUIC send_alert callback error");
        }
        return ret;
    }
#endif

#ifdef HAVE_WRITE_DUP
    if (ssl->dupWrite && ssl->dupSide == READ_DUP_SIDE) {
        int notifyErr = 0;

        WOLFSSL_MSG("Read dup side cannot write alerts, notifying sibling");

        if (type == close_notify) {
            notifyErr = ZERO_RETURN;
        } else if (severity == alert_fatal) {
            notifyErr = FATAL_ERROR;
        }

        if (notifyErr != 0) {
            return NotifyWriteSide(ssl, notifyErr);
        }

        return 0;
    }
#endif

    ssl->pendingAlert.code = type;
    ssl->pendingAlert.level = severity;

   #ifdef OPENSSL_EXTRA
        if (ssl->CBIS != NULL) {
            ssl->CBIS(ssl, WOLFSSL_CB_ALERT, type);
        }
   #endif
   #ifdef WOLFSSL_DTLS
        if (ssl->options.dtls)
           dtlsExtra = DTLS_RECORD_EXTRA;
   #endif

    /* check for available size */
    outputSz = ALERT_SIZE + MAX_MSG_EXTRA + dtlsExtra;
    if ((ret = CheckAvailableSize(ssl, outputSz)) != 0) {
#ifdef WOLFSSL_DTLS
        /* If CheckAvailableSize returned WANT_WRITE due to a blocking write
         * then discard pending output and just send the alert. */
        if (ssl->options.dtls) {
            if (ret != WC_NO_ERR_TRACE(WANT_WRITE) || severity != alert_fatal)
                return ret;
            ShrinkOutputBuffer(ssl);
            if ((ret = CheckAvailableSize(ssl, outputSz)) != 0) {
                return ret;
            }
        }
        else {
            return ret;
        }
#else
        return ret;
#endif
    }

    /* Check output buffer */
    if (ssl->buffers.outputBuffer.buffer == NULL)
        return BUFFER_E;

    /* get output buffer */
    output = GetOutputBuffer(ssl);
    input[0] = (byte)severity;
    input[1] = (byte)type;
    ssl->alert_history.last_tx.code = type;
    ssl->alert_history.last_tx.level = severity;
    if (severity == alert_fatal) {
#ifdef WOLFSSL_DTLS
        /* Mark as closed in dtls only once we enter stateful mode. */
        if (!ssl->options.dtls || ssl->options.dtlsStateful)
#endif
            ssl->options.isClosed = 1;  /* Don't send close_notify */
    }

Once wolfSSL decides to send a fatal alert, it records the alert in alert_history and sets ssl->options.isClosed = 1.

src/internal.c:27781-28163

int SendData(WOLFSSL* ssl, const void* data, size_t sz)
{
    word32 sent = 0; /* plainText size */
    int sendSz,
        ret;
    int error = ssl->error;

    ...

    /* don't allow write after decrypt or mac error */
    if (error == WC_NO_ERR_TRACE(VERIFY_MAC_ERROR) ||
        error == WC_NO_ERR_TRACE(DECRYPT_ERROR)) {
        ...
        return WOLFSSL_FATAL_ERROR;
    }

    ...

    if (!ssl->options.tls1_3) {
        sendSz = BuildMessage(ssl, out, outputSz, sendBuffer, buffSz,
                              application_data, 0, 0, 1, CUR_ORDER);
    }
    else {
#ifdef WOLFSSL_TLS13
        sendSz = BuildTls13Message(ssl, out, outputSz, sendBuffer, buffSz,
                                   application_data, 0, 0, 1);
#endif
    }

    ...

    if ( (error = SendBuffered(ssl)) < 0) {
        ...
        if (error == WC_NO_ERR_TRACE(SOCKET_ERROR_E) &&
                (ssl->options.connReset || ssl->options.isClosed)) {
            ...
            return 0;  /* peer reset or closed */
        }
        return error;
    }

SendData() has no upfront isClosed/connReset gate that rejects application writes once a fatal alert has been sent. It only blocks a narrow pair of read-side crypto errors (VERIFY_MAC_ERROR and DECRYPT_ERROR) and otherwise proceeds to build an application_data record. The isClosed check appears only later in an error-cleanup branch after a send attempt has already failed.

src/ssl_api_rw.c:216-223

    ret = SendData(ssl, data, sz);

    WOLFSSL_LEAVE("wolfSSL_write", ret);

    if (ret < 0)
        return WOLFSSL_FATAL_ERROR;
    else
        return ret;

If SendData() returns a positive length, wolfSSL_write_internal() reports success back to the caller. There is no higher-level post-fatal closure check here either.

Runtime Evidence

Round 1

The first pass completed a normal TLS 1.3 handshake as a positive control. Its generic family wrapper had no requirement-specific native injection path, so it could not decide whether a write was possible after a fatal alert. That limitation motivated the focused probe below and is not treated as evidence for or against the issue.

Round 2 (2026-08-03 focused reprobe)

  • Status: confirmed
  • Exit code: 0

This focused probe was recompiled and rerun against the audited harness build on August 3, 2026. It completes a TLS 1.3 handshake, injects a server-side encrypted EndOfEarlyData handshake record to force a client-side fatal unexpected_message alert, and then immediately calls wolfSSL_write() on that same client object.

Observed output:

handshake_ok c2s_len=0 s2c_len=0
after_fatal read_ret=-1 read_err=-394 last_tx_level=2 last_tx_code=10 c2s_len=24 c2s_records=1
after_write write_ret=18 write_err=0 c2s_len=64 c2s_records=2
confirmed_post_fatal_write_issue

Interpretation:

  • last_tx_level=2 and last_tx_code=10 show the client transmitted a fatal unexpected_message alert.
  • Immediately after that fatal alert, wolfSSL_write() still returned success (write_ret=18).
  • The outbound client-to-server record count increased from 1 to 2, proving a second TLS record was queued after the fatal alert.
  • The byte count increased from 24 to 64, which is consistent with a new encrypted application-data record being added after the alert record.

Inconsistency Reason

  • RFC 8446 Section 6.2 requires that once a fatal error condition is encountered, the implementation must close the connection without sending any additional data.
  • wolfSSL's fatal-alert send path does mark the connection closed by setting ssl->options.isClosed = 1.
  • However, the later application-write path does not check that closed state before building and queuing a fresh application_data record.
  • The focused runtime probe confirmed the consequence of that gap: one fatal alert record was sent first, and a second application-data record was successfully sent afterwards on the same connection.

Decision Reason

  • This is a concrete, repeatable violation of the RFC rule, not a generic proof gap.
  • The dynamic reproducer confirms the forbidden behavior on the audited build, and the source explains exactly why it happens.

Remaining Uncertainty

  • No material uncertainty remains for this requirement. The exact set of fatal-trigger paths affected may be broader than the single reproducer exercised here, but one concrete TLS 1.3 path already demonstrates a standards violation.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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