Ticket age wraparound breaks TLS 1.3 and DTLS 1.3 early data freshness checks
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:
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:
gcc -O2 -g -Wall -Wextra -Werror \
-Iinclude ticket-age-wrap-reproducer.c \
libssl.a libcrypto.a -ldl -pthread \
-o ticket-age-wrap-reproducerRun it with the test certificate and key:
./ticket-age-wrap-reproducer test/certs/servercert.pem test/certs/serverkey.pemOn 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 dataWith 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 dataThe 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.
Source: openssl/openssl