RFC 9846 issue: `pre_shared_key` trailing data is accepted
Nobody has claimed this yet.
- Dominant language
- C
- Stars
- 30.8k
- Forks
- 11.5k
- Avg merge
- 10m
- Merged PRs (30d)
- 1
Description
RFC 9846 issue: pre_shared_key trailing data is accepted
Summary
OpenSSL accepts a resumed TLS 1.3 ClientHello whose pre_shared_key extension has one extra byte after the PreSharedKeyExtension structure. RFC 9846 requires an implemented, non-ignored extension parser to abort the handshake with decode_error when bytes remain after the extension-specific structure has been parsed.
Standard Requirement
- Official standard: RFC 9846 Section 4.3, extension processing
- Related structure: RFC 9846 Section 4.3.11,
Pre-Shared Key Extension
Normative receiver requirement:
When processing an extension, receivers MUST abort the handshake with a "decode_error" alert if there is data left over after parsing the structure.
RFC 9846 defines pre_shared_key extension data as a PreSharedKeyExtension. In a ClientHello this selects OfferedPsks, which consists of the identities vector followed by the binders vector. The section does not specify that trailing bytes are allowed for pre_shared_key, and OpenSSL implements this extension, so the unimplemented-or-ignored-extension exception does not apply.
Relevant Source Code
pre_shared_key is a built-in TLS 1.3 extension and is parsed on the server side by tls_parse_ctos_psk.
/* ssl/statem/extensions.c:488-494 */
{ /* Required by the TLSv1.3 spec to always be the last extension */
TLSEXT_TYPE_psk,
SSL_EXT_CLIENT_HELLO | SSL_EXT_TLS1_3_SERVER_HELLO
| SSL_EXT_TLS_IMPLEMENTATION_ONLY | SSL_EXT_TLS1_3_ONLY,
OSSL_ECH_HANDLING_CALL_BOTH,
NULL, tls_parse_ctos_psk, tls_parse_stoc_psk, tls_construct_stoc_psk,
tls_construct_ctos_psk, final_psk }
The generic dispatcher directly returns the built-in parser result and does not add a universal post-parse remaining-byte check.
/* ssl/statem/extensions.c:1073-1076 */
parser = s->server ? extdef->parse_ctos : extdef->parse_stoc;
if (parser != NULL)
return parser(s, &currext->data, context, x, chainidx);
The server-side PSK parser reads the two length-prefixed vectors with PACKET_get_length_prefixed_2(). After successful binder verification, it stores the session and returns success without checking that the outer extension packet has been exhausted.
/* ssl/statem/extensions_srvr.c:1315-1318 */
if (!PACKET_get_length_prefixed_2(pkt, &identities)) {
SSLfatal(s, SSL_AD_DECODE_ERROR, SSL_R_BAD_EXTENSION);
return 0;
}
/* ssl/statem/extensions_srvr.c:1541-1568 */
if (!PACKET_get_length_prefixed_2(pkt, &binders)) {
SSLfatal(s, SSL_AD_DECODE_ERROR, SSL_R_BAD_EXTENSION);
goto err;
}
for (i = 0; i <= id; i++) {
if (!PACKET_get_length_prefixed_1(&binders, &binder)) {
SSLfatal(s, SSL_AD_DECODE_ERROR, SSL_R_BAD_EXTENSION);
goto err;
}
}
if (PACKET_remaining(&binder) != (size_t)hashsize) {
SSLfatal(s, SSL_AD_DECODE_ERROR, SSL_R_BAD_EXTENSION);
goto err;
}
if (tls_psk_do_binder(s, md, PACKET_msg_start(pkt), binderoffset,
PACKET_data(&binder), NULL, sess, 0, ext)
!= 1) {
/* SSLfatal() already called */
goto err;
}
s->ext.tick_identity = id;
SSL_SESSION_free(s->session);
s->session = sess;
return 1;
PACKET_get_length_prefixed_2() consumes one length-prefixed vector but permits bytes to remain in the original packet. OpenSSL has a strict helper, PACKET_as_length_prefixed_2(), which fails when leftover bytes remain, but tls_parse_ctos_psk does not use it for the binder vector.
/* include/internal/packet.h:594-610 */
__owur static ossl_inline int PACKET_get_length_prefixed_2(PACKET *pkt,
PACKET *subpkt)
{
unsigned int length;
const unsigned char *data;
PACKET tmp = *pkt;
if (!PACKET_get_net_2(&tmp, &length) || !PACKET_get_bytes(&tmp, &data, (size_t)length)) {
return 0;
}
*pkt = tmp;
subpkt->curr = data;
subpkt->msgstart = pkt->msgstart;
subpkt->remaining = length;
return 1;
}
/* include/internal/packet.h:613-625 */
/*
* Like PACKET_get_length_prefixed_2, but additionally, fails when there are
* leftover bytes in |pkt|.
*/
__owur static ossl_inline int PACKET_as_length_prefixed_2(PACKET *pkt,
PACKET *subpkt)
{
unsigned int length;
const unsigned char *data;
PACKET tmp = *pkt;
if (!PACKET_get_net_2(&tmp, &length) || !PACKET_get_bytes(&tmp, &data, (size_t)length) || PACKET_remaining(&tmp) != 0) {
Implementation Behavior
The server processes pre_shared_key as an implemented built-in extension. In the resumed ClientHello path, tls_parse_ctos_psk parses identities, finds a usable session, parses binders, verifies the selected binder, and returns success. Because the parser does not check PACKET_remaining(pkt) after reading the binders vector, one extra byte after the PreSharedKeyExtension structure remains unreported and the handshake continues.
This is narrower than a generic all-extension claim: other extension parsers such as psk_key_exchange_modes do reject appended bytes. The confirmed gap is the pre_shared_key server parser.
Inconsistency Reason
RFC 9846 requires a receiver to abort with decode_error when it processes an implemented extension and finds bytes left over after parsing that extension's structure. OpenSSL implements pre_shared_key, parses it during TLS 1.3 resumption, but accepts a ClientHello where the pre_shared_key extension body is lengthened from 251 to 252 bytes by appending one byte after the OfferedPsks structure.
The implementation therefore continues the handshake in a condition where RFC 9846 requires decode_error.
Runtime Evidence
Two focused runtime checks were used. The older TLSProxy check demonstrates a complete resumed handshake with a malformed pre_shared_key extension. The current packet-level check was run again during review and confirms that, with a valid binder, the server does not reject the extra byte at extension parsing time.
Complete resumption run
Action: I ran a TLSProxy resumption reproducer that appends one byte to the resumed ClientHello pre_shared_key extension.
The TLSProxy filter appends one byte to the resumed ClientHello pre_shared_key extension and repacks the message:
# review-tools/psk_trailing_probe.pl:66-74
my $data = $message->extension_data->{TLSProxy::Message::EXT_PSK};
die "pre_shared_key extension not present in resumed ClientHello\n"
unless defined $data;
my $new_data = $data . "\x00";
print "MODIFY ClientHello pre_shared_key length "
. length($data) . " -> " . length($new_data) . "\n";
$message->set_extension(TLSProxy::Message::EXT_PSK, $new_data);
$message->repack();
Command, executed from the OpenSSL build root with relative inputs:
HARNESS_VERBOSE=1 SRCTOP=$PWD BLDTOP=$PWD PERL5LIB=$PWD/util/perl \
perl ../../opt/runs/rfc9846/rfc9846-openssl/001-050/review-tools/psk_trailing_probe.pl
Observed output:
INITIAL_SUCCESS 1
MODIFY ClientHello pre_shared_key length 251 -> 252
Reused, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
RESUME_SUCCESS 1
RESUME_TLS13 1
RESUME_ALERT_SIDE server
RESUME_ALERT_DESC 0
The initial connection produced a TLS 1.3 session. The resumed connection used a pre_shared_key extension whose body was lengthened from 251 to 252 bytes by appending one byte after the OfferedPsks structure. OpenSSL still completed TLS 1.3 resumption and did not send a fatal decode_error; the observed alert description was 0, close_notify.
Current packet-level run
Action: I ran a packet-level external-PSK proxy reproducer.
This run uses an external TLS 1.3 PSK so the proxy can independently verify and recompute the binder. The mutation keeps the binder valid, then appends one byte after the pre_shared_key extension structure:
# review-tools/direct_external_psk_trailing_proxy_probe.py:190-209
old_binder = bytes(rec[binder_start:binder_end])
old_calc = tls13_binder(PSK, bytes(rec[5:binders_len_pos]))
old_match = old_calc == old_binder
rec[ext_data_end:ext_data_end] = b"\x00"
rec[3:5] = (rec_len + 1).to_bytes(2, "big")
rec[6:9] = (hs_len + 1).to_bytes(3, "big")
rec[ext_len_pos:ext_len_pos + 2] = (ext_len + 1).to_bytes(2, "big")
rec[ext_data_len_pos:ext_data_len_pos + 2] = (ext_data_len + 1).to_bytes(2, "big")
new_binder = tls13_binder(PSK, bytes(rec[5:binders_len_pos]))
rec[binder_start:binder_end] = new_binder
Command, executed from the OpenSSL build root with relative inputs:
python3 ../../opt/runs/rfc9846/rfc9846-openssl/001-050/review-tools/direct_external_psk_trailing_proxy_probe.py
Observed output:
MUTATION_LOG [{'old_len': 58, 'new_len': 59, 'old_match': True, 'binder_len': 32, 'binderoffset': 1624}]
CLIENT_REUSED_TLS13 True
SERVER_WROTE_SERVER_HELLO True
SERVER_WROTE_FINISHED True
DECODE_ERROR False
DECRYPT_ERROR False
BAD_RECORD_MAC True
old_match: True confirms that the proxy understood the original binder and that the modified ClientHello was sent with a recomputed binder of the expected length. The server did not send decode_error; instead, it advanced past ClientHello extension parsing and wrote ServerHello and Finished. The final bad_record_mac is a later transcript divergence caused by the transparent proxy changing the ClientHello bytes without changing the client's local transcript, so it does not explain away the missing decode_error at the extension parsing point.
Control run
Action: I ran a control probe that appends trailing data to psk_key_exchange_modes, another implemented ClientHello extension.
MODIFY ClientHello extension psk_kex_modes type 45 length 2 -> 3
SCENARIO psk_kex_modes
SUCCESS 0
TLS13 0
ALERT_SIDE server
ALERT_DESC 50
The control shows that the same TLSProxy approach observes the expected server-side decode_error(50) when trailing data is added to psk_key_exchange_modes. That makes the pre_shared_key acceptance specific to the PSK parser behavior rather than a generic harness artifact.
Impact
A malformed TLS 1.3 resumed ClientHello with trailing data in an implemented pre_shared_key extension is accepted instead of being rejected with decode_error. This is a protocol compliance failure in extension parsing strictness.
Fix Direction
Make tls_parse_ctos_psk reject outer extension bytes left after the binder vector. A minimal direction is to replace the binder-vector read with the strict length-prefixed helper, or add an explicit PACKET_remaining(pkt) == 0 check immediately after reading binders, failing with SSL_AD_DECODE_ERROR on leftover bytes. The fix should preserve existing successful resumption behavior for well-formed pre_shared_key extensions and add a regression test matching review-tools/psk_trailing_probe.pl.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with tls_parse_ctos_psk in ssl/statem/extensions_srvr.c and the packet helpers in include/internal/packet.h; compare the existing review-tools/psk_trailing_probe.pl reproducer with the psk_key_exchange_modes control. Run the focused resumption probe and add a regression test for a valid-binder ClientHello with one trailing byte, with completion indicated by a server-side decode_error while well-formed resumption still succeeds.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c
- Domain
- cryptography, networking, security
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100