Skip to content

Zeroize TLS key material in SSL object destructors - #3547

Open
unlong wants to merge 1 commit into
aws:mainfrom
unlong:ssl-secret-zeroization
Open

unlong wants to merge 1 commit into
aws:mainfrom
unlong:ssl-secret-zeroization

Conversation

@unlong

@unlong unlong commented Sep 18, 2026

Copy link
Copy Markdown

Related issues

Addresses #3494

Context and motivation

ssl_session_st::secret — the TLS 1.2 master secret, and the TLS 1.3 resumption PSK — is never zeroized, so it stays intact in the heap allocation after the session is released. SSL_SESSION_dup copies it into fresh allocations that go through the same non-cleansing destructor, so a single handshake can leave several independent copies behind.

Per @torben-hansen's note on the issue, the same applies to other objects holding key material: SSL_HANDSHAKE, SSL_HANDSHAKE_HINTS, and SSL3_STATE. Today there is not a single OPENSSL_cleanse call anywhere under ssl/.

This is defense in depth, not a fix for an exploitable bug. There is no memory-disclosure primitive here; it only matters against an attacker who can already read freed heap memory via a core dump, swap, a hypervisor snapshot, or a separate use-after-free.

Description of changes

Cleanse key material in the destructor of each object that owns it:

  • ssl_session_stsecret. This also covers every SSL_SESSION_dup copy, since they all release through this path.
  • SSL_HANDSHAKE — the seven SSL_MAX_MD_SIZE secret arrays (secret_, early_traffic_secret_, both handshake secrets, both traffic secrets, expected_client_finished_) and the TLS 1.2 key_block.
  • SSL_HANDSHAKE_HINTSkey_share_secret, ecdhe_private_key, decrypted_psk, decrypted_ticket. This struct had no destructor at all; one is added.
  • SSL3_STATEwrite_traffic_secret, read_traffic_secret, exporter_secret.

Array::Reset releases through a plain OPENSSL_free, so a CleanseArray(Array<uint8_t> *) helper is added next to Array for the heap-backed fields. Member destructors run after the enclosing destructor body, so cleansing Array contents from the destructor happens before the allocation is released.

Scoped to the objects named in the issue. SSLKeyShare private keys and the stack-local key_block in ssl/handoff.cc are left alone as follow-up work.

Testing

Two regression tests in ssl/ssl_misc_test.cc cover SSL_HANDSHAKE and SSL3_STATE. Each placement-news the object into caller-owned storage, fills every secret field with a known pattern, records the field offsets, runs the destructor explicitly, and asserts the bytes are zero. Because the storage outlives the object, reading it after destruction is well defined. Reverting either destructor change fails the corresponding test.

ssl_session_st is not covered. Its destructor is private to RefCounted, and RefCounted::DecRefInternal calls OPENSSL_free immediately after it, so the same technique would mean reading freed memory. Adding a friend declaration to ssl/internal.h purely for the test seemed like the worse tradeoff, but happy to do it if you would rather have the coverage.

I was not able to build locally (no CMake on hand), so the new tests have not been executed — I have only confirmed that every non-test file under ssl/ and the test file itself pass clang++ -fsyntax-only -std=c++17. Relying on CI here, and I will follow up on any failures.

Review considerations

No public API or ABI change; all four types are internal to libssl. No FIPS boundary impact.

SSL_HANDSHAKE_HINTS gains a user-declared destructor, which makes it non-trivially-destructible. It is only ever created via MakeUnique and is never moved or aggregate-initialized, so this should be inert, but it is the one change worth a second look.

🤖 Generated with Claude Code

The TLS 1.2 master secret / TLS 1.3 resumption PSK in |ssl_session_st|,
the TLS 1.3 traffic secrets in |SSL_HANDSHAKE| and |SSL3_STATE|, and the
key material in |SSL_HANDSHAKE_HINTS| were all left intact in freed heap
memory. Cleanse them in the corresponding destructors so they do not
outlive the objects that own them.

This is defense in depth, not a fix for an exploitable bug: it only
matters against an attacker who can already read freed heap memory, via
a core dump, swap, a hypervisor snapshot, or a separate use-after-free.

Member destructors run after the enclosing destructor body, so cleansing
|Array<uint8_t>| contents there happens before the allocation is
released.

Addresses aws#3494

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@unlong
unlong requested a review from a team as a code owner September 18, 2026 02:28
@unlong
unlong deployed to manual-approval September 18, 2026 02:29 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant