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.
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
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-28463Once wolfSSL decides to send a fatal alert, it records the alert in
alert_historyand setsssl->options.isClosed = 1.src/internal.c:27781-28163SendData()has no upfrontisClosed/connResetgate that rejects application writes once a fatal alert has been sent. It only blocks a narrow pair of read-side crypto errors (VERIFY_MAC_ERRORandDECRYPT_ERROR) and otherwise proceeds to build anapplication_datarecord. TheisClosedcheck appears only later in an error-cleanup branch after a send attempt has already failed.src/ssl_api_rw.c:216-223If
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)
confirmed0This 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
EndOfEarlyDatahandshake record to force a client-side fatalunexpected_messagealert, and then immediately callswolfSSL_write()on that same client object.Observed output:
Interpretation:
last_tx_level=2andlast_tx_code=10show the client transmitted a fatalunexpected_messagealert.wolfSSL_write()still returned success (write_ret=18).1to2, proving a second TLS record was queued after the fatal alert.24to64, which is consistent with a new encrypted application-data record being added after the alert record.Inconsistency Reason
ssl->options.isClosed = 1.application_datarecord.Decision Reason
Remaining Uncertainty