Commit dca561d4cd for openssl.org

commit dca561d4cdb84c7e813e9b28352928edd5b2cfd8
Author: Jakub Zelenka <jakub.zelenka@openssl.foundation>
Date:   Tue Apr 28 19:22:46 2026 +0200

    Integrate mfail functionality to fuzz tests

    Run the fuzz corpora under mfail in addition to the normal path, so the
    existing inputs also exercise malloc-failure handling. The fuzz.pl harness
    sizes the mfail runs to a time budget and, on a leak, bisects down to the
    exact file and injection point. Adds a budgeted asan/ubsan CI job to run it.

    Assisted-by: Claude:claude-opus-4-6

    Reviewed-by: Nikola Pajkovsky <nikolap@openssl.org>
    Reviewed-by: Tomas Mraz <tomas@openssl.foundation>
    MergeDate: Wed Jul 15 16:01:43 2026
    (Merged from https://github.com/openssl/openssl/pull/30944)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b10ab496c5..597501112f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -462,6 +462,40 @@ jobs:
         path: artifacts.tar.gz
         if-no-files-found: ignore

+  fuzz_tests_mfail:
+    runs-on: ubuntu-latest
+    timeout-minutes: 30
+    steps:
+    - uses: actions/checkout@v6
+      with:
+        persist-credentials: false
+    - name: checkout fuzz/corpora submodule
+      run: git submodule update --init --depth 1 fuzz/corpora
+    - name: Adjust ASLR for sanitizer
+      run: sudo sysctl -w vm.mmap_rnd_bits=28
+    - name: config
+      run: |
+        ./config --strict-warnings --banner=Configured --debug \
+          -DPEDANTIC -DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION \
+          enable-asan enable-ec_explicit_curves enable-ubsan \
+          enable-rc5 enable-md2 enable-ec_nistp_64_gcc_128 \
+          enable-weak-ssl-ciphers enable-nextprotoneg
+        perl configdata.pm --dump
+    - name: make
+      run: make -s -j4
+    - name: make test (fuzz with mfail)
+      env:
+        OSSL_FUZZ_TEST_BUDGET: 1200
+        OSSL_FUZZ_TEST_JOBS: 4
+      run: .github/workflows/make-test OPENSSL_TEST_RAND_ORDER=0 TESTS="test_fuzz*"
+    - name: save artifacts
+      if: success() || failure()
+      uses: actions/upload-artifact@v5
+      with:
+        name: "ci@fuzz_tests_mfail"
+        path: artifacts.tar.gz
+        if-no-files-found: ignore
+
   memory_sanitizer:
     runs-on: ubuntu-latest
     steps:
diff --git a/fuzz/build.info b/fuzz/build.info
index 3770c2df7e..5c410260e4 100644
--- a/fuzz/build.info
+++ b/fuzz/build.info
@@ -176,6 +176,8 @@ IF[{- !$disabled{"fuzz-afl"} || !$disabled{"fuzz-libfuzzer"} -}]
 ENDIF

 IF[{- !$disabled{tests} -}]
+  $FUZZTESTSRC=test-corpus.c ../test/mfail/mfail.c
+
   PROGRAMS{noinst}=asn1-test asn1parse-test bignum-test bndiv-test client-test conf-test crl-test server-test smime-test
   PROGRAMS{noinst}=pkcs12-test punycode-test pem-test decoder-test hashtable-test acert-test
   PROGRAMS{noinst}=v3name-test
@@ -218,131 +220,131 @@ IF[{- !$disabled{tests} -}]
     PROGRAMS{noinst}=dtlsclient-test dtlsserver-test
   ENDIF

-  SOURCE[asn1-test]=asn1.c test-corpus.c fuzz_rand.c
-  INCLUDE[asn1-test]=../include
-  DEPEND[asn1-test]=../libcrypto ../libssl
+  SOURCE[asn1-test]=asn1.c $FUZZTESTSRC fuzz_rand.c
+  INCLUDE[asn1-test]=../include ../test/mfail
+  DEPEND[asn1-test]=../libcrypto.a ../libssl.a

-  SOURCE[asn1parse-test]=asn1parse.c test-corpus.c
-  INCLUDE[asn1parse-test]=../include
-  DEPEND[asn1parse-test]=../libcrypto
+  SOURCE[asn1parse-test]=asn1parse.c $FUZZTESTSRC
+  INCLUDE[asn1parse-test]=../include ../test/mfail
+  DEPEND[asn1parse-test]=../libcrypto.a

-  SOURCE[bignum-test]=bignum.c test-corpus.c
-  INCLUDE[bignum-test]=../include
-  DEPEND[bignum-test]=../libcrypto
+  SOURCE[bignum-test]=bignum.c $FUZZTESTSRC
+  INCLUDE[bignum-test]=../include ../test/mfail
+  DEPEND[bignum-test]=../libcrypto.a

-  SOURCE[bndiv-test]=bndiv.c test-corpus.c
-  INCLUDE[bndiv-test]=../include
-  DEPEND[bndiv-test]=../libcrypto
+  SOURCE[bndiv-test]=bndiv.c $FUZZTESTSRC
+  INCLUDE[bndiv-test]=../include ../test/mfail
+  DEPEND[bndiv-test]=../libcrypto.a

-  SOURCE[client-test]=client.c test-corpus.c fuzz_rand.c
-  INCLUDE[client-test]=../include
-  DEPEND[client-test]=../libcrypto ../libssl
+  SOURCE[client-test]=client.c $FUZZTESTSRC fuzz_rand.c
+  INCLUDE[client-test]=../include ../test/mfail
+  DEPEND[client-test]=../libcrypto.a ../libssl.a

-  SOURCE[cmp-test]=cmp.c test-corpus.c fuzz_rand.c
-  INCLUDE[cmp-test]=../include
+  SOURCE[cmp-test]=cmp.c $FUZZTESTSRC fuzz_rand.c
+  INCLUDE[cmp-test]=../include ../test/mfail
   DEPEND[cmp-test]=../libcrypto.a
   # referring to static lib allows using non-exported functions

-  SOURCE[ml-kem-test]=ml-kem.c test-corpus.c fuzz_rand.c
-  INCLUDE[ml-kem-test]=../include
+  SOURCE[ml-kem-test]=ml-kem.c $FUZZTESTSRC
+  INCLUDE[ml-kem-test]=../include ../test/mfail
   DEPEND[ml-kem-test]=../libcrypto.a
   # referring to static lib allows using non-exported functions

-  SOURCE[ml-dsa-test]=ml-dsa.c test-corpus.c fuzz_rand.c
-  INCLUDE[ml-dsa-test]=../include
+  SOURCE[ml-dsa-test]=ml-dsa.c $FUZZTESTSRC
+  INCLUDE[ml-dsa-test]=../include ../test/mfail
   DEPEND[ml-dsa-test]=../libcrypto.a
   # referring to static lib allows using non-exported functions

-  SOURCE[slh-dsa-test]=slh-dsa.c test-corpus.c fuzz_rand.c
-  INCLUDE[slh-dsa-test]=../include
+  SOURCE[slh-dsa-test]=slh-dsa.c $FUZZTESTSRC fuzz_rand.c
+  INCLUDE[slh-dsa-test]=../include ../test/mfail
   DEPEND[slh-dsa-test]=../libcrypto.a
   # referring to static lib allows using non-exported functions

-  SOURCE[cms-test]=cms.c test-corpus.c
-  INCLUDE[cms-test]=../include
-  DEPEND[cms-test]=../libcrypto
+  SOURCE[cms-test]=cms.c $FUZZTESTSRC
+  INCLUDE[cms-test]=../include ../test/mfail
+  DEPEND[cms-test]=../libcrypto.a

-  SOURCE[pkcs12-test]=pkcs12.c test-corpus.c
-  INCLUDE[pkcs12-test]=../include
-  DEPEND[pkcs12-test]=../libcrypto
+  SOURCE[pkcs12-test]=pkcs12.c $FUZZTESTSRC
+  INCLUDE[pkcs12-test]=../include ../test/mfail
+  DEPEND[pkcs12-test]=../libcrypto.a

-  SOURCE[conf-test]=conf.c test-corpus.c
-  INCLUDE[conf-test]=../include
-  DEPEND[conf-test]=../libcrypto
+  SOURCE[conf-test]=conf.c $FUZZTESTSRC
+  INCLUDE[conf-test]=../include ../test/mfail
+  DEPEND[conf-test]=../libcrypto.a

-  SOURCE[crl-test]=crl.c test-corpus.c
-  INCLUDE[crl-test]=../include
-  DEPEND[crl-test]=../libcrypto
+  SOURCE[crl-test]=crl.c $FUZZTESTSRC
+  INCLUDE[crl-test]=../include ../test/mfail
+  DEPEND[crl-test]=../libcrypto.a

-  SOURCE[ct-test]=ct.c test-corpus.c
-  INCLUDE[ct-test]=../include
-  DEPEND[ct-test]=../libcrypto
+  SOURCE[ct-test]=ct.c $FUZZTESTSRC
+  INCLUDE[ct-test]=../include ../test/mfail
+  DEPEND[ct-test]=../libcrypto.a

-  SOURCE[dtlsclient-test]=dtlsclient.c test-corpus.c fuzz_rand.c
-  INCLUDE[dtlsclient-test]=../include
-  DEPEND[dtlsclient-test]=../libcrypto ../libssl
+  SOURCE[dtlsclient-test]=dtlsclient.c $FUZZTESTSRC fuzz_rand.c
+  INCLUDE[dtlsclient-test]=../include ../test/mfail
+  DEPEND[dtlsclient-test]=../libcrypto.a ../libssl.a

-  SOURCE[dtlsserver-test]=dtlsserver.c test-corpus.c fuzz_rand.c
-  INCLUDE[dtlsserver-test]=../include
-  DEPEND[dtlsserver-test]=../libcrypto ../libssl
+  SOURCE[dtlsserver-test]=dtlsserver.c $FUZZTESTSRC fuzz_rand.c
+  INCLUDE[dtlsserver-test]=../include ../test/mfail
+  DEPEND[dtlsserver-test]=../libcrypto.a ../libssl.a

-  SOURCE[pem-test]=pem.c test-corpus.c
-  INCLUDE[pem-test]=../include
+  SOURCE[pem-test]=pem.c $FUZZTESTSRC
+  INCLUDE[pem-test]=../include ../test/mfail
   DEPEND[pem-test]=../libcrypto.a

-  SOURCE[decoder-test]=decoder.c test-corpus.c fuzz_rand.c
-  INCLUDE[decoder-test]=../include
-  DEPEND[decoder-test]=../libcrypto
+  SOURCE[decoder-test]=decoder.c $FUZZTESTSRC fuzz_rand.c
+  INCLUDE[decoder-test]=../include ../test/mfail
+  DEPEND[decoder-test]=../libcrypto.a

-  SOURCE[hashtable-test]=hashtable.c test-corpus.c fuzz_rand.c
-  INCLUDE[hashtable-test]=../include
+  SOURCE[hashtable-test]=hashtable.c $FUZZTESTSRC fuzz_rand.c
+  INCLUDE[hashtable-test]=../include ../test/mfail
   DEPEND[hashtable-test]=../libcrypto.a

-  SOURCE[acert-test]=acert.c test-corpus.c
-  INCLUDE[acert-test]=../include
-  DEPEND[acert-test]=../libcrypto
+  SOURCE[acert-test]=acert.c $FUZZTESTSRC
+  INCLUDE[acert-test]=../include ../test/mfail
+  DEPEND[acert-test]=../libcrypto.a

-  SOURCE[punycode-test]=punycode.c test-corpus.c
-  INCLUDE[punycode-test]=../include
+  SOURCE[punycode-test]=punycode.c $FUZZTESTSRC
+  INCLUDE[punycode-test]=../include ../test/mfail
   DEPEND[punycode-test]=../libcrypto.a

-  SOURCE[smime-test]=smime.c test-corpus.c
-  INCLUDE[smime-test]=../include
-  DEPEND[smime-test]=../libcrypto ../libssl
+  SOURCE[smime-test]=smime.c $FUZZTESTSRC
+  INCLUDE[smime-test]=../include ../test/mfail
+  DEPEND[smime-test]=../libcrypto.a ../libssl.a

-  SOURCE[v3name-test]=v3name.c test-corpus.c
-  INCLUDE[v3name-test]=../include
+  SOURCE[v3name-test]=v3name.c $FUZZTESTSRC
+  INCLUDE[v3name-test]=../include ../test/mfail
   DEPEND[v3name-test]=../libcrypto.a

-  SOURCE[quic-client-test]=quic-client.c test-corpus.c fuzz_rand.c
-  INCLUDE[quic-client-test]=../include
+  SOURCE[quic-client-test]=quic-client.c $FUZZTESTSRC fuzz_rand.c
+  INCLUDE[quic-client-test]=../include ../test/mfail
   DEPEND[quic-client-test]=../libcrypto.a ../libssl.a

-  SOURCE[quic-server-test]=quic-server.c test-corpus.c fuzz_rand.c
-  INCLUDE[quic-server-test]=../include
+  SOURCE[quic-server-test]=quic-server.c $FUZZTESTSRC fuzz_rand.c
+  INCLUDE[quic-server-test]=../include ../test/mfail
   DEPEND[quic-server-test]=../libcrypto.a ../libssl.a

-  SOURCE[quic-srtm-test]=quic-srtm.c test-corpus.c fuzz_rand.c
-  INCLUDE[quic-srtm-test]=../include
+  SOURCE[quic-srtm-test]=quic-srtm.c $FUZZTESTSRC fuzz_rand.c
+  INCLUDE[quic-srtm-test]=../include ../test/mfail
   DEPEND[quic-srtm-test]=../libcrypto.a ../libssl.a

-  SOURCE[quic-lcidm-test]=quic-lcidm.c test-corpus.c fuzz_rand.c
-  INCLUDE[quic-lcidm-test]=../include
+  SOURCE[quic-lcidm-test]=quic-lcidm.c $FUZZTESTSRC fuzz_rand.c
+  INCLUDE[quic-lcidm-test]=../include ../test/mfail
   DEPEND[quic-lcidm-test]=../libcrypto.a ../libssl.a

-  SOURCE[quic-rcidm-test]=quic-rcidm.c test-corpus.c fuzz_rand.c
-  INCLUDE[quic-rcidm-test]=../include
+  SOURCE[quic-rcidm-test]=quic-rcidm.c $FUZZTESTSRC fuzz_rand.c
+  INCLUDE[quic-rcidm-test]=../include ../test/mfail
   DEPEND[quic-rcidm-test]=../libcrypto.a ../libssl.a

-  SOURCE[server-test]=server.c test-corpus.c fuzz_rand.c
-  INCLUDE[server-test]=../include
-  DEPEND[server-test]=../libcrypto ../libssl
+  SOURCE[server-test]=server.c $FUZZTESTSRC fuzz_rand.c
+  INCLUDE[server-test]=../include ../test/mfail
+  DEPEND[server-test]=../libcrypto.a ../libssl.a

-  SOURCE[x509-test]=x509.c test-corpus.c fuzz_rand.c
-  INCLUDE[x509-test]=../include
-  DEPEND[x509-test]=../libcrypto
+  SOURCE[x509-test]=x509.c $FUZZTESTSRC fuzz_rand.c
+  INCLUDE[x509-test]=../include ../test/mfail
+  DEPEND[x509-test]=../libcrypto.a

-  SOURCE[provider-test]=provider.c test-corpus.c
-  INCLUDE[provider-test]=../include
-  DEPEND[provider-test]=../libcrypto
+  SOURCE[provider-test]=provider.c $FUZZTESTSRC
+  INCLUDE[provider-test]=../include ../test/mfail
+  DEPEND[provider-test]=../libcrypto.a
 ENDIF
diff --git a/fuzz/test-corpus.c b/fuzz/test-corpus.c
index 783df1a076..a908f69644 100644
--- a/fuzz/test-corpus.c
+++ b/fuzz/test-corpus.c
@@ -1,5 +1,5 @@
 /*
- * Copyright 2016-2020 The OpenSSL Project Authors. All Rights Reserved.
+ * Copyright 2016-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.
@@ -17,10 +17,13 @@
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
+#include <time.h>
 #include <sys/stat.h>
 #include <openssl/crypto.h>
+#include <openssl/err.h>
 #include "fuzzer.h"
 #include "internal/o_dir.h"
+#include "mfail.h"

 #if defined(_WIN32) && defined(_MAX_PATH) && !defined(PATH_MAX)
 #define PATH_MAX _MAX_PATH
@@ -34,7 +37,45 @@
 #define S_ISREG(m) ((m) & S_IFREG)
 #endif

-static void testfile(const char *pathname)
+static double secs_since(clock_t start)
+{
+    return (double)(clock() - start) / CLOCKS_PER_SEC;
+}
+
+static void run_baseline(const unsigned char *buf, size_t s)
+{
+    FuzzerTestOneInput(buf, s);
+}
+
+static void run_mfail(const unsigned char *buf, size_t s,
+    const char *path, int file_idx)
+{
+    mfail_init(file_idx, MFAIL_FLAG_COUNT);
+    while (mfail_has_next()) {
+        if (mfail_get_phase() == MFAIL_PHASE_COUNTING)
+            fprintf(stderr,
+                "# MFAIL_BEGIN file_idx=%d phase=count\n", file_idx);
+        else
+            fprintf(stderr,
+                "# MFAIL_BEGIN file_idx=%d point=%d/%d\n",
+                file_idx, mfail_get_point(), mfail_get_total());
+
+        mfail_start();
+        FuzzerTestOneInput(buf, s);
+        mfail_end();
+
+        if (mfail_get_phase() == MFAIL_PHASE_COUNTING) {
+            fprintf(stderr, "# %s: %d allocations\n", path, mfail_get_count());
+        } else {
+            fprintf(stderr, "# %s: point %d/%d %s\n", path,
+                mfail_get_point(), mfail_get_total(),
+                mfail_was_triggered() ? "hit" : "unreached");
+        }
+        ERR_clear_error();
+    }
+}
+
+static void testfile(const char *pathname, int file_idx)
 {
     struct stat st;
     FILE *f;
@@ -43,26 +84,52 @@ static void testfile(const char *pathname)

     if (stat(pathname, &st) < 0 || !S_ISREG(st.st_mode))
         return;
-    printf("# %s\n", pathname);
-    fflush(stdout);
+
+    fprintf(stderr, "# CORPUS_FILE file_idx=%d size=%lld path=%s\n",
+        file_idx, (long long)st.st_size, pathname);
     f = fopen(pathname, "rb");
     if (f == NULL)
         return;
     buf = malloc(st.st_size);
-    if (buf != NULL) {
-        s = fread(buf, 1, st.st_size, f);
-        OPENSSL_assert(s == (size_t)st.st_size);
-        FuzzerTestOneInput(buf, s);
-        free(buf);
+    if (buf == NULL) {
+        fclose(f);
+        return;
     }
+    s = fread(buf, 1, st.st_size, f);
+    OPENSSL_assert(s == (size_t)st.st_size);
+
+    if (mfail_is_installed())
+        run_mfail(buf, s, pathname, file_idx);
+    else
+        run_baseline(buf, s);
+
+    free(buf);
     fclose(f);
 }

 int main(int argc, char **argv)
 {
-    int n;
+    int n, mfi_rc;
+    int file_idx = 0;
+    clock_t corpus_start;

-    FuzzerInitialize(&argc, &argv);
+    mfi_rc = mfail_install(1);
+    if (mfi_rc < 0) {
+        fprintf(stderr, "mfail: failed to install allocator hooks\n");
+        return 1;
+    } else if (mfi_rc > 0) {
+        /* Disable buffering for better crash analysis */
+        setvbuf(stdout, NULL, _IOLBF, 0);
+        setvbuf(stderr, NULL, _IOLBF, 0);
+    }
+
+    if (FuzzerInitialize(&argc, &argv) < 0) {
+        if (mfail_is_installed())
+            return 0; /* init failure under mfail is expected */
+        return 1;
+    }
+
+    corpus_start = clock();

     for (n = 1; n < argc; ++n) {
         size_t dirname_len = strlen(argv[n]);
@@ -88,18 +155,20 @@ int main(int argc, char **argv)
                 pathname[dirname_len] = '\0';
             }
             strcpy(pathname + dirname_len, filename);
-            testfile(pathname);
+            testfile(pathname, file_idx++);
         }
         OPENSSL_DIR_end(&ctx);

         /* If it wasn't a directory, treat it as a file instead */
         if (!wasdir)
-            testfile(argv[n]);
+            testfile(argv[n], file_idx++);

         free(pathname);
     }

-    FuzzerCleanup();
+    if (!mfail_is_installed() || mfail_is_count_only())
+        fprintf(stderr, "# corpus_time: %.6f\n", secs_since(corpus_start));

+    FuzzerCleanup();
     return 0;
 }
diff --git a/test/recipes/fuzz.pl b/test/recipes/fuzz.pl
index 3f03eef4f7..e5182a362c 100644
--- a/test/recipes/fuzz.pl
+++ b/test/recipes/fuzz.pl
@@ -1,4 +1,4 @@
-# Copyright 2016-2020 The OpenSSL Project Authors. All Rights Reserved.
+# Copyright 2016-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
@@ -8,18 +8,295 @@
 use strict;
 use warnings;

+use Cwd qw/abs_path/;
+
 use OpenSSL::Glob;
-use OpenSSL::Test qw/:DEFAULT srctop_dir/;
+use OpenSSL::Test qw/:DEFAULT srctop_dir bldtop_file result_file/;

-sub fuzz_ok {
-    die "Only one argument accepted" if scalar @_ != 1;
+# print logs
+sub fuzz_dump_log {
+    my ($log) = @_;
+    return unless $ENV{HARNESS_VERBOSE} && open(my $fh, '<', $log);
+    print STDERR <$fh>;
+    close $fh;
+}
+
+# execute test and print backtrace
+sub fuzz_print_backtrace {
+    my ($f, $path, $point) = @_;
+    my $bt_log = result_file("$f-backtrace.stderr.log");
+    local $ENV{OPENSSL_TEST_MFAIL_BACKTRACE} = 1;
+    local $ENV{OPENSSL_TEST_MFAIL_POINT} = $point if defined $point;
+    run(fuzz(["$f-test", $path], stderr => $bt_log));
+
+    diag("- backtrace at injection point:");
+    if (open(my $fh, '<', $bt_log)) {
+        while (my $line = <$fh>) {
+            chomp $line;
+            diag("  $line");
+        }
+        close $fh;
+    }
+}
+
+# check fuzz test logs
+sub fuzz_check_log {
+    my ($f, $log, $exit_ok, $silent_leak) = @_;
+
+    my $has_leaks = 0;
+    my $corpus_time = 0;
+    my @tail;
+    my ($last_path, $last_point);
+
+    # collect info from the executed fuzz log
+    if (open(my $fh, '<', $log)) {
+        while (my $line = <$fh>) {
+            chomp $line;
+            if ($line =~ /^#\s/) {
+                $corpus_time = $1
+                    if $line =~ /^#\s*corpus_time:\s*([\d.]+)/;
+                $last_path  = $1 if $line =~ /\bpath=(.+?)\s*$/;
+                $last_point = $1 if $line =~ /\bpoint=(\d+)\//;
+                @tail = ($line);
+            } else {
+                push @tail, $line;
+                $has_leaks = 1
+                    if $line =~ /^(?:Direct|Indirect) leak of \d+ byte/;
+            }
+        }
+        close $fh;
+    }
+
+    # return if the test passed and there is no leak
+    return (1, 0, $corpus_time) if $exit_ok && !$has_leaks;
+    # return if there is a leak but it's silent (should not be reported)
+    return (0, $has_leaks, $corpus_time) if $has_leaks && $silent_leak;
+
+    # log leak info
+    my $why = !$exit_ok ? "non-zero exit" : "leaks (clean exit)";
+    diag("fuzz $f failed: $why");
+    diag("- full stderr: $log");
+    if (defined $last_path) {
+        my $bin  = abs_path(bldtop_file('fuzz', "$f-test"));
+        my $path = abs_path($last_path);
+        my $env  = defined $last_point
+            ? "OPENSSL_TEST_MFAIL_POINT=$last_point " : "";
+        diag("- reproduce: $env$bin $path");
+    }
+    diag($_) for @tail;
+    fuzz_print_backtrace($f, $last_path, $last_point)
+        if defined $last_path && !$has_leaks;
+    return (0, $has_leaks, $corpus_time);
+}
+
+# run fuzz test and dump logs
+sub fuzz_run {
+    my ($f, $d, $log_name, $silent_leak) = @_;
+    my $log = result_file($log_name);
+    my $exit_ok = run(fuzz(["$f-test", $d], stderr => $log));
+    fuzz_dump_log($log);
+    my ($passed, $leaks, $corpus_time) = fuzz_check_log($f, $log, $exit_ok,
+                                                        $silent_leak);
+    return ($passed, $leaks, $corpus_time, $log);
+}
+
+# run fuzz test in counting mode and no fail run
+sub fuzz_run_count_only {
+    my ($f, $d) = @_;
+    my $log = result_file("$f-count.stderr.log");
+    local $ENV{OPENSSL_TEST_MFAIL_COUNT_ONLY} = 1;
+    my $exit_ok = run(fuzz(["$f-test", $d], stderr => $log));
+    fuzz_dump_log($log);
+
+    my ($corpus_time, $cur, @allocs) = (0, undef);
+    if (open(my $fh, '<', $log)) {
+        while (my $line = <$fh>) {
+            $corpus_time = $1 if $line =~ /^#\s*corpus_time:\s*([\d.]+)/;
+            $cur = $1 if $line =~ /^#\s*CORPUS_FILE\s+file_idx=(\d+)/;
+            push @allocs, $1 + 0 if defined $cur
+                && $line =~ /:\s*(\d+)\s+allocations\s*$/;
+        }
+        close $fh;
+    }
+    return ($exit_ok, $corpus_time, \@allocs);
+}
+
+# find path and point of the reported leak for easy recreation
+sub fuzz_mfail_bisect {
+    my ($f, $log) = @_;
+
+    # collect all paths and executed points from the output
+    my (%path, %points);
+    if (open(my $fh, '<', $log)) {
+        while (my $line = <$fh>) {
+            $path{$1} = $2
+                if $line =~
+                    /CORPUS_FILE\s+file_idx=(\d+)\s+size=\d+\s+path=(\S+)/;
+            push @{$points{$1}}, $2
+                if $line =~ /MFAIL_BEGIN\s+file_idx=(\d+)\s+point=(\d+)\/\d+/;
+        }
+        close $fh;
+    }
+
+    # reset current envs so they don't get used in bisect run
+    delete local $ENV{OPENSSL_TEST_MFAIL_COUNT};
+    delete local $ENV{OPENSSL_TEST_MFAIL_START};
+    delete local $ENV{OPENSSL_TEST_MFAIL_POINT};
+
+    diag("bisecting mfail leak across isolated point reruns");
+
+    # go through all executed corpus files
+    for my $idx (sort { $a <=> $b } keys %path) {
+        # go trhout all executed points in path
+        for my $p (@{$points{$idx} || []}) {
+            local $ENV{OPENSSL_TEST_MFAIL_POINT} = $p;
+            my $plog = result_file("$f-bisect-$idx-$p.stderr.log");
+            my $exit_ok = run(fuzz(["$f-test", $path{$idx}], stderr => $plog),
+                              quiet => 1);
+            # silently skip runs without a leak
+            my (undef, $leaks) = fuzz_check_log($f, $plog, $exit_ok, 1);
+            next unless $leaks;
+            # report exact leak location
+            my $bin = abs_path(bldtop_file('fuzz', "$f-test"));
+            my $abs = abs_path($path{$idx});
+            diag("isolated leak: file_idx=$idx point=$p path=$path{$idx}");
+            diag("- log: $plog");
+            diag("- reproduce: OPENSSL_TEST_MFAIL_POINT=$p $bin $abs");
+            fuzz_print_backtrace($f, $path{$idx}, $p);
+            return;
+        }
+    }
+    diag("bisection did not reproduce the leak");
+}

-    my $f = $_[0];
+# get all test_fuzz tests calling this
+sub fuzz_test_names {
+    my @names;
+    for my $p (glob(srctop_dir('test', 'recipes') . '/[0-9][0-9]-test_fuzz_*.t')) {
+        # push the actual name of the test used in TESTS filtering
+        push @names, $1 if $p =~ m{/\d+-(test_fuzz_\S+)\.t$};
+    }
+    return @names;
+}
+
+# match a test name against TESTS env filter (test/run_tests.pl semantics)
+sub fuzz_match_tests_filter {
+    my ($name, $filter) = @_;
+    return 1 unless defined $filter && $filter ne '';
+
+    my @pats = grep { length } split /\s+/, $filter;
+    return 1 unless @pats;
+
+    # a leading negative implies a starting "alltests"
+    my $included = $pats[0] =~ /^-/ ? 1 : 0;
+
+    for my $pat (@pats) {
+        # alltests resets the set to all, ignoring everything before
+        if ($pat eq 'alltests') {
+            $included = 1;
+            next;
+        }
+
+        my $neg = $pat =~ s/^-//;
+
+        # glob -> regex
+        (my $re = quotemeta $pat) =~ s/\\\*/.*/g;
+        $re =~ s/\\\?/./g;
+        next unless $name =~ /\A$re\z/;
+
+        $included = $neg ? 0 : 1;
+    }
+
+    return $included;
+}
+
+# get budget per test
+sub fuzz_per_test_budget {
+    my $budget = $ENV{OSSL_FUZZ_TEST_BUDGET} or return 0;
+    my $jobs   = $ENV{OSSL_FUZZ_TEST_JOBS} || 1;
+    my $filter = $ENV{TESTS};
+
+    my @active = grep { fuzz_match_tests_filter($_, $filter) } fuzz_test_names();
+    my $count  = scalar(@active) || 1;
+
+    # we don't need all jobs if there are less tests
+    $jobs = $count if $jobs > $count;
+
+    my $per_test = $budget * $jobs / $count;
+    diag(sprintf("budget=%ss jobs=%d active=%d -> per-test=%.3fs",
+            $budget, $jobs, $count, $per_test));
+    return $per_test;
+}
+
+sub fuzz_ok {
+    my ($f, %opts) = @_;
     my $d = srctop_dir('fuzz', 'corpora', $f);

     SKIP: {
         skip "No directory $d", 1 unless -d $d;
-        ok(run(fuzz(["$f-test", $d])), "Fuzzing $f");
+
+        my $per_test = fuzz_per_test_budget();
+        my $safety = 0.8;
+        my $target = $per_test * $safety;
+
+        # no budget configured, just run the corpus
+        unless ($per_test > 0) {
+            ok(run(fuzz(["$f-test", $d])), "Fuzzing $f");
+            return;
+        }
+
+        # baseline run to measure the corpus run time
+        my ($ok, $corpus_time, $allocs) = fuzz_run_count_only($f, $d);
+        unless ($ok) {
+            ok(0, "Fuzzing $f (count-only)");
+            return;
+        }
+
+        # get the maximum allocations in instance and count total
+        my $total_allocs = 0;
+        my $max_k = 0;
+        for (@$allocs) {
+            $total_allocs += $_;
+            $max_k = $_ if $_ > $max_k;
+        }
+        my $num_files = scalar @$allocs;
+        diag(sprintf("%s: count-only %.3fs, allocs=%d, files=%d, max=%d",
+                $f, $corpus_time, $total_allocs, $num_files, $max_k));
+
+        # baseline alone consumed the budget, nothing left for mfail
+        if ($corpus_time <= 0 || $corpus_time >= $target) {
+            ok(1, "Fuzzing $f (no mfail budget; "
+                . "corpus=${corpus_time}s, target=${target}s)");
+            return;
+        }
+
+        # no allocations counted, can't size the mfail run
+        if ($total_allocs <= 0 || $num_files <= 0) {
+            ok(1, "Fuzzing $f (no allocations counted)");
+            return;
+        }
+
+        # number of mfail iterations that fit alongside the baseline:
+        # ~corpus_time * (1 + count / 2) <= target
+        my $count = int(2 * ($target - $corpus_time) / $corpus_time);
+        # never exceed max(K_i); injections beyond that are wasted
+        $count = $max_k if $count > $max_k;
+        if ($count <= 0) {
+            ok(1, "Fuzzing $f (budget too small for mfail)");
+            return;
+        }
+        diag("$f: running mfail with count=$count");
+
+        local $ENV{OPENSSL_TEST_MFAIL_COUNT} = $count;
+        my $main_log = "$f-mfail.stderr.log";
+        my ($passed, $leaks, undef, $log) = fuzz_run($f, $d, $main_log, 1);
+
+        unless ($passed) {
+            fuzz_mfail_bisect($f, $log) if $leaks;
+            ok(0, "Fuzzing $f (mfail count=$count, per-test=${per_test}s)");
+            return;
+        }
+        ok(1, "Fuzzing $f (mfail count=$count, per-test=${per_test}s)");
     }
 }

diff --git a/util/perl/OpenSSL/Test.pm b/util/perl/OpenSSL/Test.pm
index 369d4f2b9a..0cdb41259c 100644
--- a/util/perl/OpenSSL/Test.pm
+++ b/util/perl/OpenSSL/Test.pm
@@ -448,6 +448,14 @@ If used, B<VARREF> must be a reference to a scalar variable.  It will be
 assigned a boolean indicating if the command succeeded or not.  This is
 particularly useful together with B<capture>.

+=item B<quiet =E<gt> 0|1>
+
+If true, suppress the diagnostic line that C<run> normally prints to STDERR
+after the command completes (the C<command => exitcode> line) when running
+under a non-verbose test harness.  Has no effect outside a harness or under
+C<HARNESS_VERBOSE>.  Useful for loops that invoke many commands where that
+line would be noise.
+
 =back

 Usually 1 indicates that the command was successful and 0 indicates failure.
@@ -522,19 +530,20 @@ sub run {
         ${$opts{statusvar}} = $r;
     }

-    # Restore STDOUT / STDERR on VMS
+    my $harness_quiet = $ENV{HARNESS_ACTIVE} && !$ENV{HARNESS_VERBOSE};
     if ($^O eq 'VMS') {
-        if ($ENV{HARNESS_ACTIVE} && !$ENV{HARNESS_VERBOSE}) {
+        # Restore STDOUT / STDERR on VMS
+        if ($harness_quiet) {
             close STDOUT;
             close STDERR;
             open STDOUT, '>&', $save_STDOUT or die "Can't restore STDOUT: $!";
             open STDERR, '>&', $save_STDERR or die "Can't restore STDERR: $!";
         }

-        print STDERR "$prefix$display_cmd => $e\n"
-            if !$ENV{HARNESS_ACTIVE} || $ENV{HARNESS_VERBOSE};
+        print STDERR "$prefix$display_cmd => $e\n" unless $harness_quiet;
     } else {
-        print STDERR "$prefix$display_cmd => $e\n";
+        print STDERR "$prefix$display_cmd => $e\n"
+            unless $opts{quiet} && $harness_quiet;
     }

     # At this point, $? stops being interesting, and unfortunately,