xref: /freebsd/crypto/openssl/test/cmp_extracerts_dos_test.c (revision dece5b05f577fc4c6b89af7ad48dfbdf7cc4945c)
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