Commit 6a06a6edbf for openssl.org
commit 6a06a6edbfe3ac97b72b2c630e25fcd0e1540251
Author: Neil Horman <nhorman@openssl.org>
Date: Tue Jun 30 14:52:18 2026 -0400
Add a test for restricting growth in cmp cert cache
Test to ensure that if certs are rejected we don't add them unboundedly
to the cmp contexts cert cache.
Assisted-by: Claude sonnet 4.6
Reviewed-by: Milan Broz <mbroz@openssl.org>
Reviewed-by: Tomas Mraz <tomas@openssl.foundation>
Merge-date: Mon Aug 24 12:41:40 2026
diff --git a/test/build.info b/test/build.info
index 4ee3e9afa3..5c02916b61 100644
--- a/test/build.info
+++ b/test/build.info
@@ -858,7 +858,7 @@ IF[{- !$disabled{tests} -}]
IF[{- !$disabled{cmp} -}]
PROGRAMS{noinst}=cmp_asn_test cmp_ctx_test cmp_status_test cmp_hdr_test \
cmp_protect_test cmp_msg_test cmp_vfy_test \
- cmp_server_test cmp_client_test
+ cmp_server_test cmp_client_test cmp_extracerts_dos_test
ENDIF
SOURCE[cmp_asn_test]=cmp_asn_test.c helpers/cmp_testlib.c
@@ -885,6 +885,10 @@ IF[{- !$disabled{tests} -}]
INCLUDE[cmp_msg_test]=.. ../include ../apps/include
DEPEND[cmp_msg_test]=../libcrypto.a libtestutil.a
+ SOURCE[cmp_extracerts_dos_test]=cmp_extracerts_dos_test.c helpers/cmp_testlib.c
+ INCLUDE[cmp_extracerts_dos_test]=.. ../include ../apps/include
+ DEPEND[cmp_extracerts_dos_test]=../libcrypto.a libtestutil.a
+
SOURCE[cmp_vfy_test]=cmp_vfy_test.c helpers/cmp_testlib.c
INCLUDE[cmp_vfy_test]=.. ../include ../apps/include
DEPEND[cmp_vfy_test]=../libcrypto.a libtestutil.a
diff --git a/test/cmp_extracerts_dos_test.c b/test/cmp_extracerts_dos_test.c
new file mode 100644
index 0000000000..273281c943
--- /dev/null
+++ b/test/cmp_extracerts_dos_test.c
@@ -0,0 +1,338 @@
+/*
+ * Copyright 2026 The OpenSSL Project Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License 2.0 (the "License"). You may not use
+ * this file except in compliance with the License. You can obtain a copy
+ * in the file LICENSE in the source distribution or at
+ * https://www.openssl.org/source/license.html
+ */
+
+/*
+ * Regression test for: CMP server unauthenticated memory/CPU DoS via
+ * cached extraCerts on failed protection checks.
+ *
+ * Root cause (crypto/cmp/cmp_vfy.c, ossl_cmp_msg_check_update(), current
+ * master as of this writing):
+ *
+ * res = ossl_x509_add_certs_new(&ctx->untrusted, msg->extraCerts, ...);
+ * ...
+ * res = OSSL_CMP_validate_msg(ctx, msg) || (cb...); // may be 0 (rejected)
+ *
+ * if (ctx->noCacheExtraCerts) // <-- rollback is
+ * while (num_added-- > 0) // gated on this
+ * X509_free(sk_X509_shift(ctx->untrusted)); // flag only, NOT
+ * // on the
+ * // validation
+ * // result (res)
+ *
+ * if (!res) { ...; return 0; } // certs from a REJECTED msg are kept
+ *
+ * This test exercises ossl_cmp_msg_check_update() directly -- no sockets,
+ * no HTTP server, no apps/cmp.c -- and asserts on the resulting size of
+ * ctx->untrusted. It builds a genuinely PBM-protected OSSL_CMP_MSG using
+ * the project's own internal message-creation function
+ * (ossl_cmp_genm_new(), same one exercised in test/cmp_msg_test.c) so the
+ * message is not hand-crafted to "look" rejectable -- it is rejected for a
+ * real reason (the receiving ctx has no matching secret configured), the
+ * same way OSSL_CMP_validate_msg() would reject any unauthenticated CMP
+ * request in the field.
+ *
+ * Expected results:
+ * - BEFORE the fix: untrusted_count_after == untrusted_count_before + N
+ * (every rejected message's extraCerts persist)
+ * - AFTER the fix: untrusted_count_after == untrusted_count_before
+ * (rejected messages leave no residue)
+ */
+
+#include "helpers/cmp_testlib.h"
+
+#define NUM_REJECTED_REQUESTS 25 /* "attacker" sends this many distinct certs */
+
+typedef struct test_fixture {
+ const char *test_case_name;
+ OSSL_CMP_CTX *server_ctx; /* long-lived ctx under test, mirrors srv_ctx->ctx */
+} CMP_DOS_TEST_FIXTURE;
+
+static OSSL_LIB_CTX *libctx = NULL;
+
+static CMP_DOS_TEST_FIXTURE *set_up(const char *const test_case_name)
+{
+ CMP_DOS_TEST_FIXTURE *fixture;
+
+ if (!TEST_ptr(fixture = OPENSSL_zalloc(sizeof(*fixture))))
+ return NULL;
+ fixture->test_case_name = test_case_name;
+
+ if (!TEST_ptr(fixture->server_ctx = OSSL_CMP_CTX_new(libctx, NULL))) {
+ OPENSSL_free(fixture);
+ return NULL;
+ }
+ /*
+ * Deliberately do NOT call OSSL_CMP_CTX_set1_secretValue() on the
+ * server ctx. Per OSSL_CMP_validate_msg() (crypto/cmp/cmp_vfy.c):
+ * case NID_id_PasswordBasedMAC:
+ * if (ctx->secretValue == NULL) {
+ * ossl_cmp_info(ctx, "no secret available for verifying..");
+ * ERR_raise(ERR_LIB_CMP, CMP_R_ERROR_VALIDATING_PROTECTION);
+ * return 0;
+ * }
+ * so every PBM-protected message this ctx receives is unconditionally
+ * rejected -- a deterministic, content-independent rejection path that
+ * models "missing or invalid protection" from the report's repro
+ * steps, without needing to forge a bad MAC by hand.
+ * ctx->noCacheExtraCerts is left at its default (0), exactly as in the
+ * vulnerable deployment ("not setting -no_cache_extracerts").
+ */
+ return fixture;
+}
+
+static void tear_down(CMP_DOS_TEST_FIXTURE *fixture)
+{
+ if (fixture == NULL)
+ return;
+ OSSL_CMP_CTX_free(fixture->server_ctx);
+ OPENSSL_free(fixture);
+}
+
+/* Generates a throwaway EC P-256 keypair; cheap, and key strength is
+ * irrelevant to this test. */
+static EVP_PKEY *generate_throwaway_keypair(void)
+{
+ EVP_PKEY_CTX *pctx = NULL;
+ EVP_PKEY *pkey = NULL;
+
+ if (!TEST_ptr(pctx = EVP_PKEY_CTX_new_from_name(libctx, "EC", NULL)))
+ return NULL;
+ if (!TEST_int_gt(EVP_PKEY_keygen_init(pctx), 0)
+ || !TEST_int_gt(EVP_PKEY_CTX_set_group_name(pctx, "P-256"), 0)
+ || !TEST_int_gt(EVP_PKEY_generate(pctx, &pkey), 0))
+ pkey = NULL;
+ EVP_PKEY_CTX_free(pctx);
+ return pkey;
+}
+
+/*
+ * Builds a minimal, self-signed, syntactically valid X509 with a unique
+ * subject/issuer per index, so X509_ADD_FLAG_NO_DUP cannot collapse it
+ * with any other generated cert (matching the report's exploitation
+ * requirement of "unique certificates across requests").
+ */
+static X509 *generate_unique_self_signed_cert(EVP_PKEY *pkey, int index)
+{
+ X509 *cert = NULL;
+ X509_NAME *name = NULL;
+ ASN1_INTEGER *serial = NULL;
+ char cn[64];
+
+ BIO_snprintf(cn, sizeof(cn), "attacker-cert-%d", index);
+
+ if (!TEST_ptr(cert = X509_new())
+ || !TEST_true(X509_set_version(cert, X509_VERSION_3)))
+ goto err;
+
+ if (!TEST_ptr(serial = ASN1_INTEGER_new())
+ || !TEST_true(ASN1_INTEGER_set(serial, 1000L + index))
+ || !TEST_true(X509_set_serialNumber(cert, serial)))
+ goto err;
+
+ if (!TEST_ptr(X509_gmtime_adj(X509_getm_notBefore(cert), 0))
+ || !TEST_ptr(X509_gmtime_adj(X509_getm_notAfter(cert),
+ 60L * 60L * 24L * 365L)))
+ goto err;
+
+ if (!TEST_true(X509_set_pubkey(cert, pkey)))
+ goto err;
+
+ if (!TEST_ptr(name = X509_NAME_new())
+ || !TEST_true(X509_NAME_add_entry_by_txt(name, "O", MBSTRING_ASC,
+ (unsigned char *)"cmp-dos-test",
+ -1, -1, 0))
+ || !TEST_true(X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC,
+ (unsigned char *)cn,
+ -1, -1, 0))
+ || !TEST_true(X509_set_subject_name(cert, name))
+ || !TEST_true(X509_set_issuer_name(cert, name)))
+ goto err;
+
+ if (!TEST_int_gt(X509_sign(cert, pkey, EVP_sha256()), 0))
+ goto err;
+
+ X509_NAME_free(name);
+ ASN1_INTEGER_free(serial);
+ return cert;
+
+err:
+ X509_NAME_free(name);
+ ASN1_INTEGER_free(serial);
+ X509_free(cert);
+ return NULL;
+}
+
+/*
+ * Builds a real, internally consistent, PBM-protected CMP GenMsg carrying
+ * exactly one never-before-seen self-signed cert as its sole extraCert.
+ * Uses a throwaway *client*-side OSSL_CMP_CTX purely to drive message
+ * creation/protection (ossl_cmp_genm_new() both builds the body and calls
+ * ossl_cmp_msg_protect() internally, same as in test/cmp_msg_test.c). The
+ * client ctx's secret is intentionally never shared with the server ctx
+ * under test, so the message is protected (syntactically well-formed,
+ * non-empty protection field) but NOT verifiable by the receiver -- this
+ * is what "missing or invalid protection" means for a real attacker who
+ * has no credentials, not an empty/garbage protection field.
+ */
+static OSSL_CMP_MSG *build_rejectable_msg_with_unique_cert(int index)
+{
+ OSSL_CMP_CTX *client_ctx = NULL;
+ OSSL_CMP_MSG *msg = NULL;
+ EVP_PKEY *pkey = NULL;
+ X509 *fresh_cert = NULL;
+ STACK_OF(X509) *extra = NULL;
+ unsigned char ref[16], secret[16];
+
+ if (!TEST_ptr(client_ctx = OSSL_CMP_CTX_new(libctx, NULL)))
+ goto err;
+
+ if (!TEST_ptr(pkey = generate_throwaway_keypair())
+ || !TEST_ptr(fresh_cert = generate_unique_self_signed_cert(pkey, index)))
+ goto err;
+
+ if (!TEST_ptr(extra = sk_X509_new_null())
+ || !TEST_true(sk_X509_push(extra, fresh_cert)))
+ goto err;
+ fresh_cert = NULL; /* ownership now with the stack */
+
+ if (!TEST_true(OSSL_CMP_CTX_set1_extraCertsOut(client_ctx, extra)))
+ goto err;
+
+ /* PBM protection with a secret the server ctx will never be given */
+ memset(ref, (unsigned char)(0xA0 + (index & 0x0F)), sizeof(ref));
+ memset(secret, (unsigned char)(0x50 + (index & 0x0F)), sizeof(secret));
+ if (!TEST_true(OSSL_CMP_CTX_set_option(client_ctx,
+ OSSL_CMP_OPT_UNPROTECTED_SEND, 0))
+ || !TEST_true(OSSL_CMP_CTX_set1_referenceValue(client_ctx, ref,
+ sizeof(ref)))
+ || !TEST_true(OSSL_CMP_CTX_set1_secretValue(client_ctx, secret,
+ sizeof(secret))))
+ goto err;
+
+ /* GenMsg is the lightest standard body type for this purpose */
+ if (!TEST_ptr(msg = ossl_cmp_genm_new(client_ctx)))
+ goto err;
+
+ sk_X509_pop_free(extra, X509_free);
+ X509_free(fresh_cert);
+ EVP_PKEY_free(pkey);
+ OSSL_CMP_CTX_free(client_ctx);
+ return msg;
+
+err:
+ sk_X509_pop_free(extra, X509_free);
+ X509_free(fresh_cert);
+ EVP_PKEY_free(pkey);
+ OSSL_CMP_CTX_free(client_ctx);
+ OSSL_CMP_MSG_free(msg);
+ return NULL;
+}
+
+/*
+ * Core assertion: N distinct rejected requests must not grow
+ * server_ctx->untrusted at all.
+ *
+ * Before the fix this fails with e.g.:
+ * ERROR: untrusted count after (25) != count before (0)
+ */
+static int execute_no_unbounded_growth_test(CMP_DOS_TEST_FIXTURE *fixture)
+{
+ OSSL_CMP_CTX *server_ctx = fixture->server_ctx;
+ int count_before, count_after, i;
+
+ count_before = sk_X509_num(OSSL_CMP_CTX_get0_untrusted(server_ctx));
+ if (count_before < 0)
+ count_before = 0;
+
+ for (i = 0; i < NUM_REJECTED_REQUESTS; i++) {
+ OSSL_CMP_MSG *msg = build_rejectable_msg_with_unique_cert(i);
+ int check_result;
+
+ if (!TEST_ptr(msg))
+ return 0;
+
+ check_result = ossl_cmp_msg_check_update(server_ctx, msg, NULL, 0);
+ OSSL_CMP_MSG_free(msg);
+
+ if (!TEST_int_eq(check_result, 0)) {
+ TEST_note("expected request #%d to be rejected (server ctx has"
+ " no matching PBM secret) but it was accepted -- test"
+ " setup is wrong, not exercising the rejection path",
+ i);
+ return 0;
+ }
+ }
+
+ count_after = sk_X509_num(OSSL_CMP_CTX_get0_untrusted(server_ctx));
+ if (count_after < 0)
+ count_after = 0;
+
+ if (!TEST_int_eq(count_after, count_before)) {
+ TEST_note("server_ctx->untrusted grew from %d to %d after %d"
+ " rejected requests -- failed-request extraCerts caching"
+ " bug is present (see ossl_cmp_msg_check_update() in"
+ " crypto/cmp/cmp_vfy.c)",
+ count_before, count_after,
+ NUM_REJECTED_REQUESTS);
+ return 0;
+ }
+ return 1;
+}
+
+/*
+ * Single-request variant of the same check, useful in isolation since it
+ * pins down that even ONE rejected request leaves no residue -- ruling out
+ * X509_ADD_FLAG_NO_DUP coincidentally masking the bug in the N-request test.
+ */
+static int execute_single_rejected_request_test(CMP_DOS_TEST_FIXTURE *fixture)
+{
+ OSSL_CMP_CTX *server_ctx = fixture->server_ctx;
+ OSSL_CMP_MSG *msg = build_rejectable_msg_with_unique_cert(999);
+ int count_before, count_after;
+
+ if (!TEST_ptr(msg))
+ return 0;
+
+ count_before = sk_X509_num(OSSL_CMP_CTX_get0_untrusted(server_ctx));
+ if (count_before < 0)
+ count_before = 0;
+
+ if (!TEST_int_eq(ossl_cmp_msg_check_update(server_ctx, msg, NULL, 0), 0)) {
+ OSSL_CMP_MSG_free(msg);
+ return 0;
+ }
+ OSSL_CMP_MSG_free(msg);
+
+ count_after = sk_X509_num(OSSL_CMP_CTX_get0_untrusted(server_ctx));
+ if (count_after < 0)
+ count_after = 0;
+
+ return TEST_int_eq(count_after, count_before);
+}
+
+static int test_single_rejected_request_leaves_no_residue(void)
+{
+ SETUP_TEST_FIXTURE(CMP_DOS_TEST_FIXTURE, set_up);
+ EXECUTE_TEST(execute_single_rejected_request_test, tear_down);
+ return result;
+}
+
+static int test_no_unbounded_growth_on_rejected_requests(void)
+{
+ SETUP_TEST_FIXTURE(CMP_DOS_TEST_FIXTURE, set_up);
+ EXECUTE_TEST(execute_no_unbounded_growth_test, tear_down);
+ return result;
+}
+
+int setup_tests(void)
+{
+ ADD_TEST(test_single_rejected_request_leaves_no_residue);
+ ADD_TEST(test_no_unbounded_growth_on_rejected_requests);
+ return 1;
+}
diff --git a/test/recipes/65-test_cmp_msg.t b/test/recipes/65-test_cmp_msg.t
index d104576a9d..19c17efca4 100644
--- a/test/recipes/65-test_cmp_msg.t
+++ b/test/recipes/65-test_cmp_msg.t
@@ -20,17 +20,22 @@ use lib srctop_dir('Configurations');
use lib bldtop_dir('.');
my $no_fips = disabled('fips') || ($ENV{NO_FIPS} // 0);
+my $no_ec = disabled('ec');
plan skip_all => "This test is not supported in a no-cmp build"
if disabled("cmp");
-plan tests => 2 + ($no_fips ? 0 : 1); #fips test
+plan tests => 2 + ($no_fips ? 0 : 1) + ($no_ec ? 0 : 1); #fips test and ec test
my @basic_cmd = ("cmp_msg_test",
data_file("new.key"),
data_file("server.crt"),
data_file("pkcs10.der"));
+unless ($no_ec) {
+ ok(run(test(["cmp_extracerts_dos_test"])));
+}
+
ok(run(test([@basic_cmd, "none"])));
ok(run(test([@basic_cmd, "default", srctop_file("test", "default.cnf")])));