Skip to content

Fix aliasing of der buffers - #11109

Open
padelsbach wants to merge 1 commit into
wolfSSL:masterfrom
padelsbach:der-buffer-aliasing
Open

Fix aliasing of der buffers#11109
padelsbach wants to merge 1 commit into
wolfSSL:masterfrom
padelsbach:der-buffer-aliasing

Conversation

@padelsbach

Copy link
Copy Markdown
Contributor

Description

Another approach for fixing ZD 22107, superseding previous PR 10905.

Fixes a family of UAF due to shared DER buffer between WOLFSSL_CTX and WOLFSSL objects. Ref counters are used on the DER objects to know when they can be finally freed. DER objects now use two new APIs: FreeSslDer(&der, &weOwn) and AliasSslDer(&der, &weOwn, src). For DH params, the DER buffer is copied.

Footprint impact:

  • refcount disabled (no atomics + threaded): DerBuffer unchanged; +576 B text
  • refcount enabled w/ atomics or single-threaded: DerBuffer unchanged on 64-bit,
    +4 bytes on 32-bit (no tail padding there); +712 B text
  • refcount enabled w/ mutex: +56 bytes per DerBuffer (opt-in only - the
    auto-enable never selects the mutex variant)
  • DH parameters are copied per session instead of aliased: 0 bytes unless the
    context has DH params set, 257 bytes for dh2048

Testing

New unit tests.

Checklist

  • added tests
  • updated/added doxygen
  • updated appropriate READMEs
  • Updated manual and documentation

@padelsbach
padelsbach force-pushed the der-buffer-aliasing branch from 43bca6b to bbc6b0e Compare August 7, 2026 21:13

@dgarske dgarske left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skoll Code Review

Scan type: reviewOverall recommendation: REQUEST_CHANGES
Findings: 9 total — 9 posted, 0 skipped
7 finding(s) posted as inline comments (see file-level comments below)
2 finding(s) not tied to a diff line (full detail below)

Posted findings

  • [Medium] test_scr_dhe_ctx_params_survive cannot detect the regression it is written to guard againsttests/api/test_tls_ext.c:513-577
  • [Medium] wolfSSL_set_accept_state silently swallows the DH parameter copy failuresrc/ssl_api_hs.c:1593-1598
  • [Medium] FreeDer infers "is this buffer reference counted" from possibly uninitialized memory in a public structwolfcrypt/src/asn.c:25304-25340
  • [Medium] AliasSslDer releases the object's existing buffer before it knows the new hold can be takensrc/internal.c:7545-7557
  • [Medium] No test coverage for the reference counting mechanism itself or its conditional build pathstests/api/test_tls13.c:3913-4139
  • [Low] internal.h comment overstates the ownership invariantwolfssl/internal.h:5328-5340
  • [Low] FreeSslDer dereferences its out-parameters without the NULL guard FreeDer providessrc/internal.c:7520-7532

Findings not tied to a diff line

CTX-supplied DH parameters are now freed at end of handshake, breaking WOLFSSL object reuse (NO_DH_PARAMS)

File: src/internal.c:7693-7729 (CopySSL_CTX_DhParams), src/internal.c:10192-10198 (FreeHandshakeResources)
Function: CopySSL_CTX_DhParams / FreeHandshakeResources
Severity: High

CopySSL_CTX_DhParams() gives every session its own copy of the context's DH parameters and sets ssl->buffers.weOwnDH = 1. FreeHandshakeResources() is unchanged and still frees the parameters whenever weOwnDH is set:

if (ssl->buffers.weOwnDH) {
    XFREE(ssl->buffers.serverDH_G.buffer, ...); ssl->buffers.serverDH_G.buffer = NULL;
    XFREE(ssl->buffers.serverDH_P.buffer, ...); ssl->buffers.serverDH_P.buffer = NULL;
}

Before this PR, parameters inherited from the CTX had weOwnDH == 0, so this block skipped them and they survived the handshake. The diff even deletes the two /* parameters (p,g) may be owned by ctx */ comments that documented exactly this. Now the parameters are freed and NULLed while ssl->options.haveDH stays 1, so any subsequent use of the same WOLFSSL object hits ERROR_OUT(NO_DH_PARAMS, exit_sske) in SendServerKeyExchange (src/internal.c:39080).

Note this only reproduces when secure renegotiation is not active on the session; with SCR enabled FreeHandshakeResources() early-returns at internal.c:10044-10051 and never reaches the free. That is why the PR's own new test does not catch it (see the separate finding on test_scr_dhe_ctx_params_survive).

Empirically verified. Identical ./configure --enable-dh --enable-opensslextra --disable-shared on both commits, TLS 1.2 DHE-RSA-AES128-GCM-SHA256 server reusing one WOLFSSL object via wolfSSL_clear():

=== PR (HEAD) ===
server handshake 1: OK (err=0)
  before clear: P=(nil) weOwnDH=1 haveDH=1
server handshake 2: FAILED (err=-319)     <-- NO_DH_PARAMS

=== BASE (HEAD~1)…

**Recommendation:** Do not let `FreeHandshakeResources()` drop the per-session copy of the CTX's DH parameters, or re-populate them in `wolfSSL_clear()`. Whichever route is chosen, add a regression test that completes a TLS 1.2 DHE handshake *without* secure renegotiation, calls `wolfSSL_clear()`, and drives a second handshake on the same object.

_Referenced code: `src/internal.c:7693-7729 (CopySSL_CTX_DhParams), src/internal.c:10192-10198 (FreeHandshakeResources)` (15 lines)_

---

#### Two existing unit tests fail on this branch; make check is broken across every configuration tested
**File:** `tests/api/test_ssl_hs.c:815, tests/api/test_ssl_hs.c:1031`
**Function:** `test_wolfSSL_set_accept_state_reinit / test_wolfSSL_set_connect_state_dh`
**Severity:** High

`tests/api/test_ssl_hs.c` is not in the PR's changed-file list, but both of its DH ownership tests assert the pre-PR aliasing contract and now fail:

2057: test_wolfSSL_set_accept_state_reinit :
ERROR - tests/api/test_ssl_hs.c line 815 failed with:
expected: ssl->buffers.serverDH_P.buffer == ctx->serverDH_P.buffer
result: 0x5744fb32d620 != 0x5744fb328fa0

2061: test_wolfSSL_set_connect_state_dh :
ERROR - tests/api/test_ssl_hs.c line 1031 failed with:
expected: ssl->buffers.serverDH_P.buffer == ctx->serverDH_P.buffer
result: 0x5744fb32d620 != 0x5744fb328fa0


`test_wolfSSL_set_connect_state_dh` additionally asserts `ExpectIntEQ(ssl->buffers.weOwnDH, 0)` and `ExpectPtrEq(ssl2->buffers.serverDH_P.buffer, ctx->serverDH_P.buffer)` under the comment "Parameters borrowed from the context are left for the context to free" - all three expectations are now inverted by the switch to per-session copies.

Reproduced on four configurations, all with `2/.../2175` failing and HEAD~1 passing `unit_test: Success for all configured tests`:
- `--enable-dh --enable-secure-renegotiation --enable-opensslextra`
- `--enable-dh --enable-opensslextra`
- `--enable-dh --enable-opensslextra CFLAGS=-DWOLFSSL_NO_DER_REFCOUNT`
- `--enable-experimental --enable-dual-alg-certs --enable-opensslextra`

The oversight is visible in the PR itself: the new `test_tls13_accept_state_dh_copy` in tests/api/test_tls13.c is a near-duplicate of the DH block in `test_wolfSSL_set_accept_state_reinit`, asserting `ExpectPtrNE(ssl->buffers.serverDH_P.buffer, ctx->serverDH_P.buffer)` - the exact…

**Recommendation:** Update both tests in `tests/api/test_ssl_hs.c` to the new copy semantics and re-run `make check`. Consider folding `test_tls13_accept_state_dh_copy` into the existing `test_wolfSSL_set_accept_state_reinit` DH block rather than keeping two tests that assert opposite things about the same code path.

_Referenced code: `tests/api/test_ssl_hs.c:815, tests/api/test_ssl_hs.c:1031-1043` (13 lines)_

---

*Review generated by [Skoll](https://github.com/wolfSSL/fenrir/tree/main/skoll)*

Comment thread tests/api/test_tls_ext.c
return EXPECT_RESULT();
}

/* A TLS 1.2 server whose DH parameters came from the context must still have

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [Medium] test_scr_dhe_ctx_params_survive cannot detect the regression it is written to guard against

The test's header comment states the intent precisely: "A TLS 1.2 server whose DH parameters came from the context must still have them for a renegotiation: the first handshake's cleanup must not take them away." But the test calls wolfSSL_UseSecureRenegotiation() on both ends, and FreeHandshakeResources() bails out before ever reaching the DH block when SCR is enabled:

/* src/internal.c:10044-10051 */
#ifdef HAVE_SECURE_RENEGOTIATION
    if (ssl->secure_renegotiation && ssl->secure_renegotiation->enabled &&
            !ssl->secure_renegotiation->advertiseOnly) {
        WOLFSSL_MSG("Secure Renegotiation needs to retain handshake resources");
        return;
    }
#endif

So "the first handshake's cleanup" never runs in this test, and the assertions ExpectNotNull(ssl_s->buffers.serverDH_P.buffer) hold trivially - on HEAD~1 and on HEAD alike. I confirmed with an instrumented handshake in an SCR build that the session reaches wolfSSL_clear with scrEn=1 and P still non-NULL. Meanwhile the real regression (see the NO_DH_PARAMS finding) happens on the non-SCR path this test never touches. In a default build the test is skipped entirely.

Secondly, the renegotiation assertion is weak: (void)wolfSSL_Rehandshake(ssl_c); discards the result and ExpectIntNE(wolfSSL_get_error(ssl_s, 0), NO_DH_PARAMS) passes if the renegotiation failed for any other reason, or never got far enough to try a ServerKeyExchange.

Fix: Add a non-SCR variant that drives a full DHE handshake, lets FreeHandshakeResources() run, and then asserts the parameters are still present (and that a second handshake on the same object succeeds). Also assert the wolfSSL_Rehandshake() return value instead of discarding it.

Comment thread src/ssl_api_hs.c
@@ -1592,9 +1592,9 @@ void wolfSSL_set_accept_state(WOLFSSL* ssl)

#ifndef NO_DH

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [Medium] wolfSSL_set_accept_state silently swallows the DH parameter copy failure

The old code inherited the CTX's DH parameter pointers and unconditionally set haveDH = 1; it could not fail. CopySSL_CTX_DhParams() allocates, so it can now return MEMORY_E, and the new code drops that on the floor:

if ((!ssl->options.haveDH) && (ssl->ctx->haveDH)) {
    if (CopySSL_CTX_DhParams(ssl, ssl->ctx) == 0) {
        ssl->options.haveDH = 1;
    }
}

On allocation failure haveDH stays 0, nothing is logged, ssl->error is not set, and wolfSSL_set_accept_state() returns void. The application gets a server object that has silently lost its DHE capability and will only find out later as an obscure cipher-suite negotiation failure. Every other failure path in this function at least emits a WOLFSSL_MSG (e.g. "Unable to unmask private key", "Error initializing server side").

Fix: Log the failure with WOLFSSL_MSG and record it in ssl->error so the application can observe it via wolfSSL_get_error().

Comment thread wolfcrypt/src/asn.c
@@ -25272,15 +25304,37 @@ void FreeDer(DerBuffer** pDer)
{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [Medium] FreeDer infers "is this buffer reference counted" from possibly uninitialized memory in a public struct

DerBuffer is a public type (wolfssl/wolfcrypt/asn_public.h) and wc_FreeDer() is a public API, so applications do construct DerBuffer objects themselves. The new code decides whether a buffer participates in reference counting by reading the count out of the object:

int counted = (wolfSSL_RefCur(der->ref) > 0);

The new doxygen text in doc/dox_comments/header_files/asn_public.h states "A DerBuffer the application built itself rather than through wc_AllocDer has no hold on it and is freed here" - but that is only true if the application zero-initialized the struct. If ref.count holds uninitialized garbage greater than zero, FreeDer() decrements instead of freeing and the buffer leaks silently; if the garbage happens to be 1, wolfSSL_RefFree() runs on an uninitialized wolfSSL_Ref, which for the mutex variant means destroying a mutex that was never initialized.

The two in-tree hand-built DerBuffers (wolfcrypt/src/evp_pk.c:1926, tests/api.c:4355) both XMEMSET to zero so they are fine today, but nothing in the public contract required that before this PR.

Fix: Either document the zero-initialization requirement in the public doxygen block for wc_FreeDer/DerBuffer, or replace the count-based inference with an explicit flag that only AllocDer() sets, so the behaviour does not depend on the contents of memory wolfSSL did not initialize.

Comment thread src/internal.c
* @return 1 on success.
* @return 0 when the hold could not be taken.
*/
int AliasSslDer(DerBuffer** pDer, byte* weOwn, DerBuffer* src)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [Medium] AliasSslDer releases the object's existing buffer before it knows the new hold can be taken

AliasSslDer() frees first and acquires second:

int AliasSslDer(DerBuffer** pDer, byte* weOwn, DerBuffer* src)
{
    int ret = 1;
    FreeSslDer(pDer, weOwn);
    if (!RefDer(src)) {
        ret = 0;
    }
    else {
        *pDer = src;
    }
    return ret;
}

If RefDer() fails the object is left with *pDer == NULL - it has lost the buffer it had and gained nothing. In wolfSSL_set_SSL_CTX() the failure return is worse than that: ssl->ctx has already been swapped and the previous context already wolfSSL_CTX_free()d by the time the first AliasSslDer() runs, so the caller receives NULL from a WOLFSSL that is now missing both its old certificate and its old context.

This is only reachable with the mutex wolfSSL_Ref variant (WOLFSSL_DER_REFCOUNT forced on without atomics on a threaded build), since the atomic and single-threaded macros always report err == 0. It is nonetheless cheap to make the operation transactional.

Fix: Acquire the reference on src first and only release the previous buffer once it has succeeded, so a failed alias is a no-op rather than a destructive partial update.

Comment thread tests/api/test_tls13.c
}


/* A server whose certificate has been renewed on disk reloads it on the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [Medium] No test coverage for the reference counting mechanism itself or its conditional build paths

The PR's core mechanism - RefDer/FreeDer counting on DerBuffer - is only observed indirectly, and several of the riskiest changed paths have no coverage at all:

  • test_tls13_ctx_cert_rotation is compiled out entirely unless WOLFSSL_DER_REFCOUNT || WOLFSSL_COPY_CERT, so the WOLFSSL_NO_DER_REFCOUNT build (the #else branch of FreeSslDer, which still gates on weOwn) is never exercised for cert rotation.
  • The WOLFSSL_DUAL_ALG_CERTS alt-key swap in src/tls13.c:10478-10499 is the trickiest new code in the PR - it aliases key to altKey and keyMask to altKeyMask and takes a hold on each - and nothing tests it. I built --enable-experimental --enable-dual-alg-certs and confirmed no test touches that swap.
  • WOLFSSL_BLIND_PRIVATE_KEY mask handling changed in four places (wolfSSL_certs_clear, wolfSSL_set_SSL_CTX x2, ProcessBufferPrivKeyHandleDer), including newly unconditional FreeDer(&ssl->buffers.keyMask) calls and a newly added ssl->buffers.weOwnKey = 1. No test covers this configuration.
  • wolfssl_add_to_chain() changed signature from int weOwn to byte* weOwn and now writes *weOwn = 1 itself; no test asserts the ownership flag after wolfSSL_add0_chain_cert/wolfSSL_CTX_add1_chain_cert.

On the positive side, I ran valgrind --leak-check=full over the whole tls13 group on this branch and it was clean (0 failures, no definite leaks), and the four new TLS 1.3 tests do run and pass.

Fix: Add a direct unit test for AllocDer/RefDer/FreeDer counting that does not depend on a TLS handshake, plus a WOLFSSL_DUAL_ALG_CERTS test that drives SendTls13CertificateVerify down the WOLFSSL_CKS_SIGSPEC_ALTERNATIVE swap. Add --enable-experimental --enable-dual-alg-certs and a WOLFSSL_NO_DER_REFCOUNT build to CI for this change.

Comment thread wolfssl/internal.h
#endif
} Buffers;

/* Every DER buffer an SSL object points at is held by it: either it allocated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [Low] internal.h comment overstates the ownership invariant

The new block comment claims: "These two calls are the only way to change one of those pointers, so the hold is always taken and always let go." That is not accurate - several paths assign the pointers directly after a FreeSslDer(), and one bypasses both helpers entirely:

  • src/ssl_load.c:1413 ssl->buffers.altKey = *der;
  • src/ssl_load.c:1445 ssl->buffers.key = *der;
  • src/ssl_load.c:2475 ssl->buffers.certificate = der;
  • src/ssl_load.c:5175 *chain = newChain;
  • src/tls13.c:10485 ssl->buffers.key = ssl->buffers.altKey;
  • wolfcrypt/src/port/maxim/maxq10xx.c:2396 ssl->buffers.certificate = maxq_der;
  • src/internal.c:7566-7583 the WOLFSSL_COPY_CERT path writes straight into ssl->buffers.certificate via AllocCopyDer() with no preceding FreeSslDer()

The intended invariant is really "the pointer is only ever released through FreeSslDer, and only ever aliased through AliasSslDer" - direct assignment of a freshly allocated buffer (which already carries its own hold from AllocDer) is fine and common. Stating it accurately makes the rule easier for the next person to follow.

Fix: Reword the comment to describe the release/alias rule rather than claiming exclusivity that the code does not have.

Comment thread src/internal.c
* @param [in, out] pDer Buffer to release. May hold NULL.
* @param [in, out] weOwn Whether the buffer was this object's own.
*/
void FreeSslDer(DerBuffer** pDer, byte* weOwn)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 [Low] FreeSslDer dereferences its out-parameters without the NULL guard FreeDer provides

FreeDer(), which FreeSslDer() wraps, is defensive about its argument (if (pDer && *pDer)). FreeSslDer() unconditionally dereferences both pDer (in the non-refcount branch) and weOwn, so it is stricter than the function it forwards to:

void FreeSslDer(DerBuffer** pDer, byte* weOwn)
{
#ifdef WOLFSSL_DER_REFCOUNT
    FreeDer(pDer);
#else
    if (*weOwn) {          /* unguarded */
        FreeDer(pDer);
    }
    *pDer = NULL;          /* unguarded */
#endif
    *weOwn = 0;            /* unguarded */
}

All 21 in-tree call sites pass the address of a struct member so this cannot fire today, and for a WOLFSSL_LOCAL helper that is a defensible contract - but the asymmetry with FreeDer() is easy to misread, and the doxygen block says only "May hold NULL" (about *pDer), not that pDer and weOwn themselves must be non-NULL.

Fix: Document that pDer and weOwn must themselves be non-NULL (the same applies to AliasSslDer), so the contract is not inferred from FreeDer's looser one.

@padelsbach padelsbach mentioned this pull request Aug 8, 2026
4 tasks
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

MemBrowse Memory Report

gcc-arm-cortex-m0plus

  • FLASH: .text +40 B (+0.1%, 64,391 B / 262,144 B, total: 25% used)

gcc-arm-cortex-m3

  • FLASH: .text +156 B (+0.1%, 123,239 B / 262,144 B, total: 47% used)

gcc-arm-cortex-m4

  • FLASH: .text +256 B (+0.1%, 201,647 B / 262,144 B, total: 77% used)

gcc-arm-cortex-m4-baremetal

  • FLASH: .text +64 B (+0.1%, 67,043 B / 262,144 B, total: 26% used)

gcc-arm-cortex-m4-dtls13

  • FLASH: .text +192 B (+0.1%, 182,460 B / 1,048,576 B, total: 17% used)

gcc-arm-cortex-m4-min-ecc

  • FLASH: .text +64 B (+0.1%, 61,957 B / 262,144 B, total: 24% used)

gcc-arm-cortex-m4-openssl-compat

  • FLASH: .text +128 B (+0.0%, 774,972 B / 1,048,576 B, total: 74% used)

gcc-arm-cortex-m4-pq

  • FLASH: .text +128 B (+0.0%, 297,052 B / 1,048,576 B, total: 28% used)

gcc-arm-cortex-m4-rsa-only

  • FLASH: .text +256 B (+0.1%, 327,984 B / 1,048,576 B, total: 31% used)

gcc-arm-cortex-m4-sp-math

  • FLASH: .text +64 B (+0.1%, 61,957 B / 262,144 B, total: 24% used)

gcc-arm-cortex-m4-tls12

  • FLASH: .text +192 B (+0.2%, 124,019 B / 262,144 B, total: 47% used)

gcc-arm-cortex-m4-tls13

  • FLASH: .text +128 B (+0.1%, 237,793 B / 262,144 B, total: 91% used)

gcc-arm-cortex-m7

  • FLASH: .text +256 B (+0.1%, 201,647 B / 262,144 B, total: 77% used)

gcc-arm-cortex-m7-pq

  • FLASH: .text +192 B (+0.1%, 298,012 B / 1,048,576 B, total: 28% used)

gcc-arm-cortex-m7-tls13

  • FLASH: .text +128 B (+0.1%, 237,857 B / 262,144 B, total: 91% used)

linuxkm-pie

  • Data: __patchable_function_entries +8 B (+0.0%, 26,448 B)

linuxkm-standard

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.

2 participants