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 #define NUM_REJECTED_REQUESTS 25 /* "attacker" sends this many distinct certs */
50
51 typedef struct test_fixture {
52 const char *test_case_name;
53 OSSL_CMP_CTX *server_ctx; /* long-lived ctx under test, mirrors srv_ctx->ctx */
54 } CMP_DOS_TEST_FIXTURE;
55
56 static OSSL_LIB_CTX *libctx = NULL;
57
set_up(const char * const test_case_name)58 static CMP_DOS_TEST_FIXTURE *set_up(const char *const test_case_name)
59 {
60 CMP_DOS_TEST_FIXTURE *fixture;
61
62 if (!TEST_ptr(fixture = OPENSSL_zalloc(sizeof(*fixture))))
63 return NULL;
64 fixture->test_case_name = test_case_name;
65
66 if (!TEST_ptr(fixture->server_ctx = OSSL_CMP_CTX_new(libctx, NULL))) {
67 OPENSSL_free(fixture);
68 return NULL;
69 }
70 /*
71 * Deliberately do NOT call OSSL_CMP_CTX_set1_secretValue() on the
72 * server ctx. Per OSSL_CMP_validate_msg() (crypto/cmp/cmp_vfy.c):
73 * case NID_id_PasswordBasedMAC:
74 * if (ctx->secretValue == NULL) {
75 * ossl_cmp_info(ctx, "no secret available for verifying..");
76 * ERR_raise(ERR_LIB_CMP, CMP_R_ERROR_VALIDATING_PROTECTION);
77 * return 0;
78 * }
79 * so every PBM-protected message this ctx receives is unconditionally
80 * rejected -- a deterministic, content-independent rejection path that
81 * models "missing or invalid protection" from the report's repro
82 * steps, without needing to forge a bad MAC by hand.
83 * ctx->noCacheExtraCerts is left at its default (0), exactly as in the
84 * vulnerable deployment ("not setting -no_cache_extracerts").
85 */
86 return fixture;
87 }
88
tear_down(CMP_DOS_TEST_FIXTURE * fixture)89 static void tear_down(CMP_DOS_TEST_FIXTURE *fixture)
90 {
91 if (fixture == NULL)
92 return;
93 OSSL_CMP_CTX_free(fixture->server_ctx);
94 OPENSSL_free(fixture);
95 }
96
97 /* Generates a throwaway EC P-256 keypair; cheap, and key strength is
98 * irrelevant to this test. */
generate_throwaway_keypair(void)99 static EVP_PKEY *generate_throwaway_keypair(void)
100 {
101 EVP_PKEY_CTX *pctx = NULL;
102 EVP_PKEY *pkey = NULL;
103
104 if (!TEST_ptr(pctx = EVP_PKEY_CTX_new_from_name(libctx, "EC", NULL)))
105 return NULL;
106 if (!TEST_int_gt(EVP_PKEY_keygen_init(pctx), 0)
107 || !TEST_int_gt(EVP_PKEY_CTX_set_group_name(pctx, "P-256"), 0)
108 || !TEST_int_gt(EVP_PKEY_generate(pctx, &pkey), 0))
109 pkey = NULL;
110 EVP_PKEY_CTX_free(pctx);
111 return pkey;
112 }
113
114 /*
115 * Builds a minimal, self-signed, syntactically valid X509 with a unique
116 * subject/issuer per index, so X509_ADD_FLAG_NO_DUP cannot collapse it
117 * with any other generated cert (matching the report's exploitation
118 * requirement of "unique certificates across requests").
119 */
generate_unique_self_signed_cert(EVP_PKEY * pkey,int index)120 static X509 *generate_unique_self_signed_cert(EVP_PKEY *pkey, int index)
121 {
122 X509 *cert = NULL;
123 X509_NAME *name = NULL;
124 ASN1_INTEGER *serial = NULL;
125 char cn[64];
126
127 BIO_snprintf(cn, sizeof(cn), "attacker-cert-%d", index);
128
129 if (!TEST_ptr(cert = X509_new())
130 || !TEST_true(X509_set_version(cert, X509_VERSION_3)))
131 goto err;
132
133 if (!TEST_ptr(serial = ASN1_INTEGER_new())
134 || !TEST_true(ASN1_INTEGER_set(serial, 1000L + index))
135 || !TEST_true(X509_set_serialNumber(cert, serial)))
136 goto err;
137
138 if (!TEST_ptr(X509_gmtime_adj(X509_getm_notBefore(cert), 0))
139 || !TEST_ptr(X509_gmtime_adj(X509_getm_notAfter(cert),
140 60L * 60L * 24L * 365L)))
141 goto err;
142
143 if (!TEST_true(X509_set_pubkey(cert, pkey)))
144 goto err;
145
146 if (!TEST_ptr(name = X509_NAME_new())
147 || !TEST_true(X509_NAME_add_entry_by_txt(name, "O", MBSTRING_ASC,
148 (unsigned char *)"cmp-dos-test",
149 -1, -1, 0))
150 || !TEST_true(X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC,
151 (unsigned char *)cn,
152 -1, -1, 0))
153 || !TEST_true(X509_set_subject_name(cert, name))
154 || !TEST_true(X509_set_issuer_name(cert, name)))
155 goto err;
156
157 if (!TEST_int_gt(X509_sign(cert, pkey, EVP_sha256()), 0))
158 goto err;
159
160 X509_NAME_free(name);
161 ASN1_INTEGER_free(serial);
162 return cert;
163
164 err:
165 X509_NAME_free(name);
166 ASN1_INTEGER_free(serial);
167 X509_free(cert);
168 return NULL;
169 }
170
171 /*
172 * Builds a real, internally consistent, PBM-protected CMP GenMsg carrying
173 * exactly one never-before-seen self-signed cert as its sole extraCert.
174 * Uses a throwaway *client*-side OSSL_CMP_CTX purely to drive message
175 * creation/protection (ossl_cmp_genm_new() both builds the body and calls
176 * ossl_cmp_msg_protect() internally, same as in test/cmp_msg_test.c). The
177 * client ctx's secret is intentionally never shared with the server ctx
178 * under test, so the message is protected (syntactically well-formed,
179 * non-empty protection field) but NOT verifiable by the receiver -- this
180 * is what "missing or invalid protection" means for a real attacker who
181 * has no credentials, not an empty/garbage protection field.
182 */
build_rejectable_msg_with_unique_cert(int index)183 static OSSL_CMP_MSG *build_rejectable_msg_with_unique_cert(int index)
184 {
185 OSSL_CMP_CTX *client_ctx = NULL;
186 OSSL_CMP_MSG *msg = NULL;
187 EVP_PKEY *pkey = NULL;
188 X509 *fresh_cert = NULL;
189 STACK_OF(X509) *extra = NULL;
190 unsigned char ref[16], secret[16];
191
192 if (!TEST_ptr(client_ctx = OSSL_CMP_CTX_new(libctx, NULL)))
193 goto err;
194
195 if (!TEST_ptr(pkey = generate_throwaway_keypair())
196 || !TEST_ptr(fresh_cert = generate_unique_self_signed_cert(pkey, index)))
197 goto err;
198
199 if (!TEST_ptr(extra = sk_X509_new_null())
200 || !TEST_true(sk_X509_push(extra, fresh_cert)))
201 goto err;
202 fresh_cert = NULL; /* ownership now with the stack */
203
204 if (!TEST_true(OSSL_CMP_CTX_set1_extraCertsOut(client_ctx, extra)))
205 goto err;
206
207 /* PBM protection with a secret the server ctx will never be given */
208 memset(ref, (unsigned char)(0xA0 + (index & 0x0F)), sizeof(ref));
209 memset(secret, (unsigned char)(0x50 + (index & 0x0F)), sizeof(secret));
210 if (!TEST_true(OSSL_CMP_CTX_set_option(client_ctx,
211 OSSL_CMP_OPT_UNPROTECTED_SEND, 0))
212 || !TEST_true(OSSL_CMP_CTX_set1_referenceValue(client_ctx, ref,
213 sizeof(ref)))
214 || !TEST_true(OSSL_CMP_CTX_set1_secretValue(client_ctx, secret,
215 sizeof(secret))))
216 goto err;
217
218 /* GenMsg is the lightest standard body type for this purpose */
219 if (!TEST_ptr(msg = ossl_cmp_genm_new(client_ctx)))
220 goto err;
221
222 sk_X509_pop_free(extra, X509_free);
223 X509_free(fresh_cert);
224 EVP_PKEY_free(pkey);
225 OSSL_CMP_CTX_free(client_ctx);
226 return msg;
227
228 err:
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 OSSL_CMP_MSG_free(msg);
234 return NULL;
235 }
236
237 /*
238 * Core assertion: N distinct rejected requests must not grow
239 * server_ctx->untrusted at all.
240 *
241 * Before the fix this fails with e.g.:
242 * ERROR: untrusted count after (25) != count before (0)
243 */
execute_no_unbounded_growth_test(CMP_DOS_TEST_FIXTURE * fixture)244 static int execute_no_unbounded_growth_test(CMP_DOS_TEST_FIXTURE *fixture)
245 {
246 OSSL_CMP_CTX *server_ctx = fixture->server_ctx;
247 int count_before, count_after, i;
248
249 count_before = sk_X509_num(OSSL_CMP_CTX_get0_untrusted(server_ctx));
250 if (count_before < 0)
251 count_before = 0;
252
253 for (i = 0; i < NUM_REJECTED_REQUESTS; i++) {
254 OSSL_CMP_MSG *msg = build_rejectable_msg_with_unique_cert(i);
255 int check_result;
256
257 if (!TEST_ptr(msg))
258 return 0;
259
260 check_result = ossl_cmp_msg_check_update(server_ctx, msg, NULL, 0);
261 OSSL_CMP_MSG_free(msg);
262
263 if (!TEST_int_eq(check_result, 0)) {
264 TEST_note("expected request #%d to be rejected (server ctx has"
265 " no matching PBM secret) but it was accepted -- test"
266 " setup is wrong, not exercising the rejection path",
267 i);
268 return 0;
269 }
270 }
271
272 count_after = sk_X509_num(OSSL_CMP_CTX_get0_untrusted(server_ctx));
273 if (count_after < 0)
274 count_after = 0;
275
276 if (!TEST_int_eq(count_after, count_before)) {
277 TEST_note("server_ctx->untrusted grew from %d to %d after %d"
278 " rejected requests -- failed-request extraCerts caching"
279 " bug is present (see ossl_cmp_msg_check_update() in"
280 " crypto/cmp/cmp_vfy.c)",
281 count_before, count_after,
282 NUM_REJECTED_REQUESTS);
283 return 0;
284 }
285 return 1;
286 }
287
288 /*
289 * Single-request variant of the same check, useful in isolation since it
290 * pins down that even ONE rejected request leaves no residue -- ruling out
291 * X509_ADD_FLAG_NO_DUP coincidentally masking the bug in the N-request test.
292 */
execute_single_rejected_request_test(CMP_DOS_TEST_FIXTURE * fixture)293 static int execute_single_rejected_request_test(CMP_DOS_TEST_FIXTURE *fixture)
294 {
295 OSSL_CMP_CTX *server_ctx = fixture->server_ctx;
296 OSSL_CMP_MSG *msg = build_rejectable_msg_with_unique_cert(999);
297 int count_before, count_after;
298
299 if (!TEST_ptr(msg))
300 return 0;
301
302 count_before = sk_X509_num(OSSL_CMP_CTX_get0_untrusted(server_ctx));
303 if (count_before < 0)
304 count_before = 0;
305
306 if (!TEST_int_eq(ossl_cmp_msg_check_update(server_ctx, msg, NULL, 0), 0)) {
307 OSSL_CMP_MSG_free(msg);
308 return 0;
309 }
310 OSSL_CMP_MSG_free(msg);
311
312 count_after = sk_X509_num(OSSL_CMP_CTX_get0_untrusted(server_ctx));
313 if (count_after < 0)
314 count_after = 0;
315
316 return TEST_int_eq(count_after, count_before);
317 }
318
test_single_rejected_request_leaves_no_residue(void)319 static int test_single_rejected_request_leaves_no_residue(void)
320 {
321 SETUP_TEST_FIXTURE(CMP_DOS_TEST_FIXTURE, set_up);
322 EXECUTE_TEST(execute_single_rejected_request_test, tear_down);
323 return result;
324 }
325
test_no_unbounded_growth_on_rejected_requests(void)326 static int test_no_unbounded_growth_on_rejected_requests(void)
327 {
328 SETUP_TEST_FIXTURE(CMP_DOS_TEST_FIXTURE, set_up);
329 EXECUTE_TEST(execute_no_unbounded_growth_test, tear_down);
330 return result;
331 }
332
setup_tests(void)333 int setup_tests(void)
334 {
335 ADD_TEST(test_single_rejected_request_leaves_no_residue);
336 ADD_TEST(test_no_unbounded_growth_on_rejected_requests);
337 return 1;
338 }
339