#32834·openssl

Ticket age wraparound breaks TLS 1.3 and DTLS 1.3 early data freshness checks

Author: idrassiCreated Sep 15, 2026Updated Sep 16, 2026
Labelshelp wantedtriaged: bug

While looking into an intermittent early data test failure, I found that the server can decode a ticket's age as zero when the client's addition of ticket_age_add wraps. That can make it reject valid early data or accept an age that should fail the freshness check.

I have reproduced both cases in TLS 1.3 and DTLS 1.3 with my own Linux build from master at 223fba0b5cd0f7c5e95fbd7801e9cbe76b75fef5.

I also found the same code on these branches.

Branch Checked commit Affected protocol
openssl-4.1 3af16b56ff TLS 1.3 and DTLS 1.3
openssl-4.0 8e90035a7c TLS 1.3
openssl-3.6 5db179e151 TLS 1.3

The problem is in tls_parse_ctos_psk, where both values are converted to OSSL_TIME before subtracting the offset:

c
age = ossl_time_subtract(ossl_ms2time(ticket_agel),
    ossl_ms2time(sess->ext.tick_age_add));

ossl_time_subtract returns zero on underflow. That's different from the subtraction modulo 2^32 required by RFC 8446, section 4.2.10. For example, a ticket age of 19000 ms and an offset of 4294957295 produce an obfuscated age of 8999. Subtracting the offset should recover 19000 ms, but the server gets zero.

The practical impact seems low, and I haven't found an exploitable security issue.

I attached ticket-age-wrap-reproducer.zip containing a reproducer ticket-age-wrap-reproducer.c. It uses public OpenSSL APIs only. The program completes a TLS 1.3 handshake, keeps the connection open, calls SSL_new_session_ticket until the server naturally produces a ticket_age_add close enough to 2^32, waits 20 real seconds, then resumes with early data using that ticket and a normal control ticket.

Build it against the OpenSSL tree under test:

bash
gcc -O2 -g -Wall -Wextra -Werror \
    -Iinclude ticket-age-wrap-reproducer.c \
    libssl.a libcrypto.a -ldl -pthread \
    -o ticket-age-wrap-reproducer

Run it with the test certificate and key:

bash
./ticket-age-wrap-reproducer test/certs/servercert.pem test/certs/serverkey.pem

On the unfixed code I get output like this:

Selected wrapping ticket #364929: ticket_age_add=4294954575
Wrapping ticket: obfuscated_ticket_age=6279, recovered_age=19000 ms, early data REJECTED
Control ticket: obfuscated_ticket_age=1134112817, recovered_age=19000 ms, early data ACCEPTED
BUG REPRODUCED: only the wrapping ticket rejected early data

With the fix, the same program accepts both tickets:

Selected wrapping ticket #1266264: ticket_age_add=4294962300
Wrapping ticket: obfuscated_ticket_age=14004, recovered_age=19000 ms, early data ACCEPTED
Control ticket: obfuscated_ticket_age=1176451256, recovered_age=19000 ms, early data ACCEPTED
PASS: both tickets accepted early data

The search depends on random ticket offsets. With a 20 second wait it checks about 226,000 tickets on average before it sees a useful offset, and sometimes more. This matches the failure mode I was investigating: a normal random ticket_age_add landed near the end of the 32 bit range while the client reported an age around 19000 ms. The program just reaches that state faster by issuing many normal tickets in one process.

I will prepare a PR to address this issue.

ticket-age-wrap-reproducer.zip