1 /* 2 * Copyright 2026 The OpenSSL Project Authors. All Rights Reserved. 3 * 4 * Licensed under the Apache License 2.0 (the "License"). You may not use 5 * this file except in compliance with the License. You can obtain a copy 6 * in the file LICENSE in the source distribution or at 7 * https://www.openssl.org/source/license.html 8 */ 9 10 /* 11 * Regression test for: CMP server unauthenticated memory/CPU DoS via 12 * cached extraCerts on failed protection checks. 13 * 14 * Root cause (crypto/cmp/cmp_vfy.c, ossl_cmp_msg_check_update(), current 15 * master as of this writing): 16 * 17 * res = ossl_x509_add_certs_new(&ctx->untrusted, msg->extraCerts, ...); 18 * ... 19 * res = OSSL_CMP_validate_msg(ctx, msg) || (cb...); // may be 0 (rejected) 20 * 21 * if (ctx->noCacheExtraCerts) // <-- rollback is 22 * while (num_added-- > 0) // gated on this 23 * X509_free(sk_X509_shift(ctx->untrusted)); // flag only, NOT 24 * // on the 25 * // validation 26 * // result (res) 27 * 28 * if (!res) { ...; return 0; } // certs from a REJECTED msg are kept 29 * 30 * This test exercises ossl_cmp_msg_check_update() directly -- no sockets, 31 * no HTTP server, no apps/cmp.c -- and asserts on the resulting size of 32 * ctx->untrusted. It builds a genuinely PBM-protected OSSL_CMP_MSG using 33 * the project's own internal message-creation function 34 * (ossl_cmp_genm_new(), same one exercised in test/cmp_msg_test.c) so the 35 * message is not hand-crafted to "look" rejectable -- it is rejected for a 36 * real reason (the receiving ctx has no matching secret configured), the 37 * same way OSSL_CMP_validate_msg() would reject any unauthenticated CMP 38 * request in the field. 39 * 40 * Expected results: 41 * - BEFORE the fix: untrusted_count_after == untrusted_count_before + N 42 * (every rejected message's extraCerts persist) 43 * - AFTER the fix: untrusted_count_after == untrusted_count_before 44 * (rejected messages leave no residue) 45 */ 46 47 #include "helpers/cmp_testlib.h" 48 49 #ifndef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION 50 /* 51 * In fuzzing builds ossl_cmp_msg_check_update() deliberately lets invalid 52 * messages pass (see cmp_vfy.c), so the rejection path under test here 53 * cannot be exercised. 54 */ 55 56 #define NUM_REJECTED_REQUESTS 25 /* "attacker" sends this many distinct certs */ 57 58 typedef struct test_fixture { 59 const char *test_case_name; 60 OSSL_CMP_CTX *server_ctx; /* long-lived ctx under test, mirrors srv_ctx->ctx */ 61 } CMP_DOS_TEST_FIXTURE; 62 63 static OSSL_LIB_CTX *libctx = NULL; 64 65 static CMP_DOS_TEST_FIXTURE *set_up(const char *const test_case_name) 66 { 67 CMP_DOS_TEST_FIXTURE *fixture; 68 69 if (!TEST_ptr(fixture = OPENSSL_zalloc(sizeof(*fixture)))) 70 return NULL; 71 fixture->test_case_name = test_case_name; 72 73 if (!TEST_ptr(fixture->server_ctx = OSSL_CMP_CTX_new(libctx, NULL))) { 74 OPENSSL_free(fixture); 75 return NULL; 76 } 77 /* 78 * Deliberately do NOT call OSSL_CMP_CTX_set1_secretValue() on the 79 * server ctx. Per OSSL_CMP_validate_msg() (crypto/cmp/cmp_vfy.c): 80 * case NID_id_PasswordBasedMAC: 81 * if (ctx->secretValue == NULL) { 82 * ossl_cmp_info(ctx, "no secret available for verifying.."); 83 * ERR_raise(ERR_LIB_CMP, CMP_R_ERROR_VALIDATING_PROTECTION); 84 * return 0; 85 * } 86 * so every PBM-protected message this ctx receives is unconditionally 87 * rejected -- a deterministic, content-independent rejection path that 88 * models "missing or invalid protection" from the report's repro 89 * steps, without needing to forge a bad MAC by hand. 90 * ctx->noCacheExtraCerts is left at its default (0), exactly as in the 91 * vulnerable deployment ("not setting -no_cache_extracerts"). 92 */ 93 return fixture; 94 } 95 96 static void tear_down(CMP_DOS_TEST_FIXTURE *fixture) 97 { 98 if (fixture == NULL) 99 return; 100 OSSL_CMP_CTX_free(fixture->server_ctx); 101 OPENSSL_free(fixture); 102 } 103 104 /* Generates a throwaway EC P-256 keypair; cheap, and key strength is 105 * irrelevant to this test. */ 106 static EVP_PKEY *generate_throwaway_keypair(void) 107 { 108 EVP_PKEY_CTX *pctx = NULL; 109 EVP_PKEY *pkey = NULL; 110 111 if (!TEST_ptr(pctx = EVP_PKEY_CTX_new_from_name(libctx, "EC", NULL))) 112 return NULL; 113 if (!TEST_int_gt(EVP_PKEY_keygen_init(pctx), 0) 114 || !TEST_int_gt(EVP_PKEY_CTX_set_group_name(pctx, "P-256"), 0) 115 || !TEST_int_gt(EVP_PKEY_generate(pctx, &pkey), 0)) 116 pkey = NULL; 117 EVP_PKEY_CTX_free(pctx); 118 return pkey; 119 } 120 121 /* 122 * Builds a minimal, self-signed, syntactically valid X509 with a unique 123 * subject/issuer per index, so X509_ADD_FLAG_NO_DUP cannot collapse it 124 * with any other generated cert (matching the report's exploitation 125 * requirement of "unique certificates across requests"). 126 */ 127 static X509 *generate_unique_self_signed_cert(EVP_PKEY *pkey, int index) 128 { 129 X509 *cert = NULL; 130 X509_NAME *name = NULL; 131 ASN1_INTEGER *serial = NULL; 132 char cn[64]; 133 134 BIO_snprintf(cn, sizeof(cn), "attacker-cert-%d", index); 135 136 if (!TEST_ptr(cert = X509_new()) 137 || !TEST_true(X509_set_version(cert, X509_VERSION_3))) 138 goto err; 139 140 if (!TEST_ptr(serial = ASN1_INTEGER_new()) 141 || !TEST_true(ASN1_INTEGER_set(serial, 1000L + index)) 142 || !TEST_true(X509_set_serialNumber(cert, serial))) 143 goto err; 144 145 if (!TEST_ptr(X509_gmtime_adj(X509_getm_notBefore(cert), 0)) 146 || !TEST_ptr(X509_gmtime_adj(X509_getm_notAfter(cert), 147 60L * 60L * 24L * 365L))) 148 goto err; 149 150 if (!TEST_true(X509_set_pubkey(cert, pkey))) 151 goto err; 152 153 if (!TEST_ptr(name = X509_NAME_new()) 154 || !TEST_true(X509_NAME_add_entry_by_txt(name, "O", MBSTRING_ASC, 155 (unsigned char *)"cmp-dos-test", 156 -1, -1, 0)) 157 || !TEST_true(X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC, 158 (unsigned char *)cn, 159 -1, -1, 0)) 160 || !TEST_true(X509_set_subject_name(cert, name)) 161 || !TEST_true(X509_set_issuer_name(cert, name))) 162 goto err; 163 164 if (!TEST_int_gt(X509_sign(cert, pkey, EVP_sha256()), 0)) 165 goto err; 166 167 X509_NAME_free(name); 168 ASN1_INTEGER_free(serial); 169 return cert; 170 171 err: 172 X509_NAME_free(name); 173 ASN1_INTEGER_free(serial); 174 X509_free(cert); 175 return NULL; 176 } 177 178 /* 179 * Builds a real, internally consistent, PBM-protected CMP GenMsg carrying 180 * exactly one never-before-seen self-signed cert as its sole extraCert. 181 * Uses a throwaway *client*-side OSSL_CMP_CTX purely to drive message 182 * creation/protection (ossl_cmp_genm_new() both builds the body and calls 183 * ossl_cmp_msg_protect() internally, same as in test/cmp_msg_test.c). The 184 * client ctx's secret is intentionally never shared with the server ctx 185 * under test, so the message is protected (syntactically well-formed, 186 * non-empty protection field) but NOT verifiable by the receiver -- this 187 * is what "missing or invalid protection" means for a real attacker who 188 * has no credentials, not an empty/garbage protection field. 189 */ 190 static OSSL_CMP_MSG *build_rejectable_msg_with_unique_cert(int index) 191 { 192 OSSL_CMP_CTX *client_ctx = NULL; 193 OSSL_CMP_MSG *msg = NULL; 194 EVP_PKEY *pkey = NULL; 195 X509 *fresh_cert = NULL; 196 STACK_OF(X509) *extra = NULL; 197 unsigned char ref[16], secret[16]; 198 199 if (!TEST_ptr(client_ctx = OSSL_CMP_CTX_new(libctx, NULL))) 200 goto err; 201 202 if (!TEST_ptr(pkey = generate_throwaway_keypair()) 203 || !TEST_ptr(fresh_cert = generate_unique_self_signed_cert(pkey, index))) 204 goto err; 205 206 if (!TEST_ptr(extra = sk_X509_new_null()) 207 || !TEST_true(sk_X509_push(extra, fresh_cert))) 208 goto err; 209 fresh_cert = NULL; /* ownership now with the stack */ 210 211 if (!TEST_true(OSSL_CMP_CTX_set1_extraCertsOut(client_ctx, extra))) 212 goto err; 213 214 /* PBM protection with a secret the server ctx will never be given */ 215 memset(ref, (unsigned char)(0xA0 + (index & 0x0F)), sizeof(ref)); 216 memset(secret, (unsigned char)(0x50 + (index & 0x0F)), sizeof(secret)); 217 if (!TEST_true(OSSL_CMP_CTX_set_option(client_ctx, 218 OSSL_CMP_OPT_UNPROTECTED_SEND, 0)) 219 || !TEST_true(OSSL_CMP_CTX_set1_referenceValue(client_ctx, ref, 220 sizeof(ref))) 221 || !TEST_true(OSSL_CMP_CTX_set1_secretValue(client_ctx, secret, 222 sizeof(secret)))) 223 goto err; 224 225 /* GenMsg is the lightest standard body type for this purpose */ 226 if (!TEST_ptr(msg = ossl_cmp_genm_new(client_ctx))) 227 goto err; 228 229 sk_X509_pop_free(extra, X509_free); 230 X509_free(fresh_cert); 231 EVP_PKEY_free(pkey); 232 OSSL_CMP_CTX_free(client_ctx); 233 return msg; 234 235 err: 236 sk_X509_pop_free(extra, X509_free); 237 X509_free(fresh_cert); 238 EVP_PKEY_free(pkey); 239 OSSL_CMP_CTX_free(client_ctx); 240 OSSL_CMP_MSG_free(msg); 241 return NULL; 242 } 243 244 /* 245 * Core assertion: N distinct rejected requests must not grow 246 * server_ctx->untrusted at all. 247 * 248 * Before the fix this fails with e.g.: 249 * ERROR: untrusted count after (25) != count before (0) 250 */ 251 static int execute_no_unbounded_growth_test(CMP_DOS_TEST_FIXTURE *fixture) 252 { 253 OSSL_CMP_CTX *server_ctx = fixture->server_ctx; 254 int count_before, count_after, i; 255 256 count_before = sk_X509_num(OSSL_CMP_CTX_get0_untrusted(server_ctx)); 257 if (count_before < 0) 258 count_before = 0; 259 260 for (i = 0; i < NUM_REJECTED_REQUESTS; i++) { 261 OSSL_CMP_MSG *msg = build_rejectable_msg_with_unique_cert(i); 262 int check_result; 263 264 if (!TEST_ptr(msg)) 265 return 0; 266 267 check_result = ossl_cmp_msg_check_update(server_ctx, msg, NULL, 0); 268 OSSL_CMP_MSG_free(msg); 269 270 if (!TEST_int_eq(check_result, 0)) { 271 TEST_note("expected request #%d to be rejected (server ctx has" 272 " no matching PBM secret) but it was accepted -- test" 273 " setup is wrong, not exercising the rejection path", 274 i); 275 return 0; 276 } 277 } 278 279 count_after = sk_X509_num(OSSL_CMP_CTX_get0_untrusted(server_ctx)); 280 if (count_after < 0) 281 count_after = 0; 282 283 if (!TEST_int_eq(count_after, count_before)) { 284 TEST_note("server_ctx->untrusted grew from %d to %d after %d" 285 " rejected requests -- failed-request extraCerts caching" 286 " bug is present (see ossl_cmp_msg_check_update() in" 287 " crypto/cmp/cmp_vfy.c)", 288 count_before, count_after, 289 NUM_REJECTED_REQUESTS); 290 return 0; 291 } 292 return 1; 293 } 294 295 /* 296 * Single-request variant of the same check, useful in isolation since it 297 * pins down that even ONE rejected request leaves no residue -- ruling out 298 * X509_ADD_FLAG_NO_DUP coincidentally masking the bug in the N-request test. 299 */ 300 static int execute_single_rejected_request_test(CMP_DOS_TEST_FIXTURE *fixture) 301 { 302 OSSL_CMP_CTX *server_ctx = fixture->server_ctx; 303 OSSL_CMP_MSG *msg = build_rejectable_msg_with_unique_cert(999); 304 int count_before, count_after; 305 306 if (!TEST_ptr(msg)) 307 return 0; 308 309 count_before = sk_X509_num(OSSL_CMP_CTX_get0_untrusted(server_ctx)); 310 if (count_before < 0) 311 count_before = 0; 312 313 if (!TEST_int_eq(ossl_cmp_msg_check_update(server_ctx, msg, NULL, 0), 0)) { 314 OSSL_CMP_MSG_free(msg); 315 return 0; 316 } 317 OSSL_CMP_MSG_free(msg); 318 319 count_after = sk_X509_num(OSSL_CMP_CTX_get0_untrusted(server_ctx)); 320 if (count_after < 0) 321 count_after = 0; 322 323 return TEST_int_eq(count_after, count_before); 324 } 325 326 static int test_single_rejected_request_leaves_no_residue(void) 327 { 328 SETUP_TEST_FIXTURE(CMP_DOS_TEST_FIXTURE, set_up); 329 EXECUTE_TEST(execute_single_rejected_request_test, tear_down); 330 return result; 331 } 332 333 static int test_no_unbounded_growth_on_rejected_requests(void) 334 { 335 SETUP_TEST_FIXTURE(CMP_DOS_TEST_FIXTURE, set_up); 336 EXECUTE_TEST(execute_no_unbounded_growth_test, tear_down); 337 return result; 338 } 339 340 #endif 341 342 int setup_tests(void) 343 { 344 #ifndef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION 345 ADD_TEST(test_single_rejected_request_leaves_no_residue); 346 ADD_TEST(test_no_unbounded_growth_on_rejected_requests); 347 #endif 348 return 1; 349 } 350