Skip to content

libclamav: cache EVP_MD handles to fix per-hash provider-fetch churn and a FIPS memory leak - #1774

Open
Sewci0 wants to merge 3 commits into
Cisco-Talos:mainfrom
Sewci0:perf/cache-evp-md-fetch
Open

libclamav: cache EVP_MD handles to fix per-hash provider-fetch churn and a FIPS memory leak#1774
Sewci0 wants to merge 3 commits into
Cisco-Talos:mainfrom
Sewci0:perf/cache-evp-md-fetch

Conversation

@Sewci0

@Sewci0 Sewci0 commented Jul 21, 2026

Copy link
Copy Markdown

Summary

On OpenSSL 3, libclamav re-acquires the digest implementation on every hash operation. cl_hash_init(), cl_hash_data(), cl_hash_file_fd() and their *_ex variants each allocate a brand-new OSSL_LIB_CTX (OSSL_LIB_CTX_new()), EVP_MD_fetch() the algorithm with the "-fips" property (so non-approved MD5/SHA1 signature hashing works on FIPS-enabled hosts), then free the digest and the context.

Because the provider/method cache lives inside a library context (OSSL_LIB_CTX(3)), a freshly-allocated context starts with an empty cache, so each EVP_MD_fetch() pays the full provider-bootstrap + method-construction cost — on the order of hundreds of microseconds — and it runs for md5/sha1/sha2-256 on essentially every scanned object (whole-file and section hashes, plus the SHA2-256 clean-cache lookup in fmap_get_hash()). The per-object hashing plumbing ends up costing far more than the hashing itself.

This is not FIPS-specific: the throwaway-context path is compiled unconditionally for OpenSSL 3, so it slows scanning whether or not the host is in FIPS mode.

The per-call throwaway context was introduced in 83fd7f1 (#1589, CLAM-2879) to fix a genuine correctness bug — MD5 hashing failing on strict-FIPS hosts when fetched from the default context. That fix is correct in what it does; the only problem is that it does it on every hash instead of once.

Fix

Per the FIPS module guide, a non-default OSSL_LIB_CTX is the correct way to reach non-FIPS digests — but per EVP_MD_fetch / EVP_DigestInit(3) and the 3.0 migration guide (explicit fetching / performance), a fetched EVP_MD is a reference-counted object that should be created once and reused, not rebuilt on every operation.

So: create the shared non-FIPS OSSL_LIB_CTX once and cache each fetched bypass EVP_MD for the process lifetime, handing callers an owned reference (their existing EVP_MD_free() cleanup is unchanged). This preserves #1589's FIPS-bypass semantics exactly — the shared context is still created with OSSL_LIB_CTX_new() and digests are still fetched with the "-fips" property, so MD5/SHA1 signature hashing keeps working on strict-FIPS hosts.

The FIPS-compliant (non-bypass) path is deliberately not cached: it is still fetched from the default library context on every call, so it continues to honor the current default property query — e.g. if the embedding process enables FIPS at runtime, a non-bypass MD5/SHA1 request correctly starts failing instead of returning a stale non-FIPS handle. That default-context fetch is already inexpensive because OpenSSL caches provider methods per library context.

Benchmarks

Isolated micro-benchmark: identical hashing work both ways, differing only in how the digest is obtained — a fresh OSSL_LIB_CTX + EVP_MD_fetch() per hash vs. fetch-once-and-reuse (this change). OpenSSL 3, aarch64, 200k hashes of a 4 KiB buffer:

algorithm per-hash (stock) per-hash (cached) speedup
sha2-256 214 µs 1.8 µs ~118×
md5 214 µs 6.8 µs ~31×
  • The per-hash overhead (~210 µs) is essentially identical across algorithms, confirming the cost is the fetch/context machinery, not the digest computation.
  • Interposing OSSL_LIB_CTX_new()/EVP_MD_fetch() confirms the bypass path drops from one call per hash (e.g. 5000 fetches for 5000 hashes) to one per algorithm for the entire process (→ 1).

libclamav hashes md5/sha1/sha2-256 for essentially every scanned object — whole-file and section hashes, plus the SHA2-256 clean-cache lookup in fmap_get_hash() — so this per-object overhead is paid throughout a scan. No functional or API change; hashing outputs are identical.

Also fixes a memory leak on FIPS hosts

Independent of the performance win, removing the per-hash OSSL_LIB_CTX_new() fixes a memory leak. The pre-existing code allocates the context before the fetch and does not free it on the md == NULL return path in cl_hash_init, cl_hash_data, and cl_hash_file_fd (e.g. crypto.c:1866). On a host whose FIPS policy makes MD5 unavailable, EVP_MD_fetch(..., "md5", "-fips") returns NULL, so every hashed object leaks an OSSL_LIB_CTX — and libclamav hashes MD5 for essentially every scanned file.

Reproduced on a FIPS-enabled Ubuntu 22.04 / OpenSSL 3.0.2 host (ClamAV 1.5.3), during a recursive scan:

LibClamAV Error: cli_scan_fmap: Error initializing md5 hash context
RSS: 0.66 GB → 2.57 GB in ~25 s (~75 MB/s), monotonic, no plateau  → OOM on a full scan

(SHA-256 succeeds — it is FIPS-approved; MD5 is the one that fails.)

Stock vs. patched, exercising the exact cl_hash_init (fresh OSSL_LIB_CTX per hash) vs. shared-context code paths against a real FIPS OpenSSL 3.0.2 host — 100k MD5 hashes, each returning NULL under FIPS:

variant RSS over 100k MD5 hashes
stock (fresh OSSL_LIB_CTX per hash) +20.6 GB
patched (one shared context) +4.2 MB (flat)
control — SHA-256, stock (fetch succeeds) +3.9 MB (bounded)

The control confirms it is specifically the NULL-fetch path that leaks: when the fetch succeeds (SHA-256, or any algorithm on a non-FIPS host), even the stock per-hash-context churn stays bounded.

With a single shared context there is no per-hash context to leak. On such a host MD5 remains unavailable, but the fetch now fails cleanly and bounded (MD5-based signatures simply don't match) instead of growing without bound — graceful degradation rather than OOM.

References

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1a0fdf1c3a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread libclamav/crypto.c Outdated
@Sewci0
Sewci0 force-pushed the perf/cache-evp-md-fetch branch 4 times, most recently from bfdb1fb to a980176 Compare July 22, 2026 13:30
@Sewci0 Sewci0 changed the title libclamav: cache EVP_MD handles to avoid per-hash OpenSSL provider fetch libclamav: cache EVP_MD handles to fix per-hash provider-fetch churn and a FIPS memory leak Jul 22, 2026
@Sewci0
Sewci0 force-pushed the perf/cache-evp-md-fetch branch from d02de5f to cbe8f6b Compare July 22, 2026 15:01
@timl-yello

Copy link
Copy Markdown

This is a significant issue for anyone using this on a fips enabled system. A large scan will quickly run a server out of memory.

@Sewci0

Sewci0 commented Jul 30, 2026

Copy link
Copy Markdown
Author

@val-ms is there a chance you could look into this please? This is not only an enhancement, currently running the scan on a fips enabled system will break the machine in a matter of seconds.

@val-ms

val-ms commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@Sewci0 I or my teammate @jhumlick will take a look. 👍

@jhumlick jhumlick left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The tests do not exercise the actual leaking path
In test_hash_md5_sha1, a NULL result from cl_hash_init("md5") is simply skipped. However, EVP_MD_fetch() == NULL is precisely the FIPS condition that triggered the leak. The test can therefore pass without demonstrating that repeated failures remain bounded.
Recommended coverage:
Run many failing MD5 initializations under the strict-FIPS configuration.
Require every call to return NULL.
Run under LeakSanitizer or Valgrind and verify no per-call allocation remains.
Keep a successful SHA-256 control.
On ordinary non-FIPS CI, assert that MD5 and SHA-1 initialization succeeds instead of silently skipping it.

[P3] Harden the cache’s ownership handling
cli_get_md() ignores the result of EVP_MD_up_ref(). OpenSSL documents that it returns 1 on success and 0 on failure. If it failed, the function would still return the cache-owned pointer; the caller would later free that reference and leave a dangling entry in the cache.
It should return NULL when the reference cannot be acquired:
if (NULL != result && 1 != EVP_MD_up_ref(result)) {
result = NULL;
}
There is also a latent ownership leak if md_cache ever reaches its fixed capacity of eight: a newly fetched object is not stored, but it is still up-referenced before being returned. This is currently unreachable because to_openssl_alg() accepts only five algorithms, but the ownership logic should still distinguish cached and uncached results.

[P3] Remove obsolete local context variables and cleanup calls
Several callers retain:
OSSL_LIB_CTX *ossl_ctx = NULL;
and later call OSSL_LIB_CTX_free(ossl_ctx), even though context creation moved entirely into cli_get_md(). This is harmless because the pointer stays NULL, but removing it would make the new ownership model clearer.

@val-ms

val-ms commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

@Sewci0 we are going to scramble to assemble a patch release for 1.4.6 and 1.5.4 faster than originally planned. I wish to include your fix (specifically because of the leak fix) in those versions. It doesn't seem fair to ask you to scramble as well, so I am going to handle the requested changes my teammate identified.

@val-ms
val-ms force-pushed the perf/cache-evp-md-fetch branch from cbe8f6b to 527832a Compare August 4, 2026 22:53
@val-ms

val-ms commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

The force-push was just a rebase to get the latest from main. Will start addressing the review findings next.

Sewci0 added 2 commits August 5, 2026 00:43
…and a FIPS memory leak

On OpenSSL 3, the hashing helpers (cl_hash_init, cl_hash_data,
cl_hash_file_fd and their _ex variants) created a fresh OSSL_LIB_CTX and
called EVP_MD_fetch() on every hash, then tore both down. A freshly-allocated
library context has no method-store cache, so each fetch pays the full
provider-bootstrap + method-construction cost (on the order of hundreds of
microseconds), and this runs for md5/sha1/sha2-256 on essentially every
scanned object (whole-file and section hashes plus the SHA2-256 clean-cache
lookup in fmap_get_hash). The per-object hashing plumbing ends up costing far
more than the hashing itself, whether or not the host is in FIPS mode.

Fetch each FIPS-bypass digest once into a single shared, long-lived non-FIPS
OSSL_LIB_CTX and reuse it (a fetched EVP_MD is reference counted and safe to
share across threads; the cache is guarded by a mutex). The FIPS-bypass
semantics are unchanged -- the shared context is still created with
OSSL_LIB_CTX_new() and digests are still fetched with the "-fips" property, so
MD5/SHA1 signature hashing still works on FIPS hosts where it can. The
FIPS-compliant (non-bypass) path is intentionally not cached: it is still
fetched from the default library context on every call so it keeps honoring
the current default property query.

This also removes the per-hash OSSL_LIB_CTX_new(), which fixes a memory leak.
The previous code allocated the context before the fetch and did not free it
on the "md == NULL" return path, so on a host whose FIPS policy makes an
algorithm unavailable (e.g. MD5), every hashed object leaked an OSSL_LIB_CTX.
Because libclamav hashes MD5 for nearly every scanned file, a full scan grew
without bound until it was OOM-killed. With a single shared context there is
nothing to leak per hash; an unavailable algorithm now degrades to a clean,
bounded failure instead of exhausting memory.
…d EVP_MD cache thread-safety

Add coverage for the shared EVP_MD cache introduced by the per-hash
provider-fetch fix:

- test_hash_md5_sha1 verifies cl_hash_init/cl_update_hash/cl_finish_hash
  return correct MD5 and SHA1 digests. A NULL context is tolerated only where
  OpenSSL itself cannot fetch the digest (a FIPS policy withholding MD5/SHA1),
  asked directly via EVP_MD_fetch() so that an ordinary host cannot quietly
  skip the check; where the digest is withheld, the test instead requires many
  repeated initializations to keep failing cleanly.
- test_hash_fetch_failure_is_bounded exercises the digest-fetch failure path on
  every host rather than only FIPS ones: an unsupported algorithm name fails at
  the same early return a FIPS-restricted MD5 does -- the return that used to
  leak an OSSL_LIB_CTX per call -- with a successful sha2-256 hash on either
  side as a control. Under the existing Valgrind test this asserts that no
  per-call allocation survives; against the pre-fix code the same test reports
  ~2000 definitely-lost blocks, failing "ERROR SUMMARY: 0 errors".
- test_hash_cache_threadsafe hammers cl_hash_init() from multiple threads to
  exercise the cache and its mutex under contention.

check_clamav now links OpenSSL::Crypto so the tests can ask OpenSSL directly
which digests it is able to supply.
@Sewci0
Sewci0 force-pushed the perf/cache-evp-md-fetch branch from 527832a to e8a274c Compare August 4, 2026 23:48
@Sewci0

Sewci0 commented Aug 4, 2026

Copy link
Copy Markdown
Author

Thanks for the review @jhumlick — all three items are addressed in e8a274c. The fixes are folded into the two existing commits, rebased onto current main.

[P2] The tests do not exercise the actual leaking path

Agreed, the skip made it vacuous. Two changes:

1. test_hash_md5_sha1 no longer skips on NULL. A NULL context is now tolerated only where OpenSSL itself cannot supply the digest, asked directly rather than inferred from the code under test:

static bool openssl_has_digest(const char *ossl_alg)
{
    EVP_MD *md = EVP_MD_fetch(NULL, ossl_alg, NULL);
    ...
}

So on ordinary non-FIPS CI, MD5/SHA-1 initialization must succeed and produce the right digest. Where the digest genuinely is withheld, the test instead requires 2000 repeated initializations to keep failing cleanly.

2. New test_hash_fetch_failure_is_bounded exercises the fetch-failure path on every host, not only FIPS ones. An algorithm name libclamav maps to no OpenSSL digest fails at the same early return a FIPS-restricted MD5 does — the if (!(md)) return NULL; after OSSL_LIB_CTX_new() — so non-FIPS CI covers the leak too. A successful sha2-256 hash on either side is the control.

Verified on Debian 12 / OpenSSL 3.0.20 via ctest -R libclamav_valgrind (which runs check_clamav under Valgrind and requires ERROR SUMMARY: 0 errors), changing only crypto.c:

crypto.c Valgrind result
this PR definitely lost: 0 bytes in 0 blocksERROR SUMMARY: 0 errors
pre-fix definitely lost: 330,024 bytes in 1,990 blocks / indirectly lost: 84,209,882 bytesERROR SUMMARY: 9 errors

The ~1,990 lost blocks track the 2000 failing iterations, with OSSL_LIB_CTX_new in every leak trace. So the new test turns the leak into a CI failure on ordinary runners. Both libclamav and libclamav_valgrind pass on this branch.

To be explicit about the enforcement point: the ck_asserts pass either way — it is Valgrind/LSan that catches the leak, which matches your suggestion to lean on those jobs.

One limitation worth flagging: I could not force a real strict-FIPS fetch failure portably in a unit test. The bypass context comes from OSSL_LIB_CTX_new(), which does not load OPENSSL_CONF, so a config-based simulation cannot reach it — I verified that pointing OPENSSL_CONF at a null-provider-only config still yields EVP_MD_fetch(ctx, "md5", "-fips") = SUCCESS in a fresh context. A genuine repro needs a FIPS-capable OpenSSL install. The unsupported-algorithm path is the same leaking return, just reached for a different reason.

[P3] Harden the cache's ownership handling

Done. cli_get_md() now tracks whether the digest is cache-owned and only up-refs in that case, checking the result:

done:
    /* A cached digest belongs to md_cache, so give the caller its own reference
     * to EVP_MD_free(). If that reference can't be acquired, fail instead of
     * handing back one the caller would free out from under the cache.
     * A digest that was fetched but not cached is already the caller's to free. */
    if (cached && NULL != result && 1 != EVP_MD_up_ref(result)) {
        cli_errmsg("cli_get_md: Failed to acquire a reference to the %s digest\n", ossl_alg);
        result = NULL;
    }

That also resolves the latent capacity-full case you spotted: a digest fetched but not cached is returned as the caller's own reference with no extra up-ref, so cached and uncached results no longer share ownership logic.

[P3] Remove obsolete local context variables and cleanup calls

Removed: 6 dead OSSL_LIB_CTX *ossl_ctx = NULL; declarations and all 16 OSSL_LIB_CTX_free(ossl_ctx) calls, across cl_hash_data_ex, cl_hash_init_ex, cl_hash_file_fd_ex, cl_hash_data, cl_hash_file_fd and cl_hash_init. No ossl_ctx reference remains in crypto.c.

Two notes

  • check_clamav now links OpenSSL::Crypto, so the test can ask OpenSSL directly which digests it can supply.
  • Unrelated pre-existing issue I hit while wiring this up: undefined reference to cl_hash_data_ex. None of the cl_hash_*_ex functions are listed in libclamav/libclamav.map, so although they are declared in clamav.h they are not exported from the shared library. The new tests avoid them (they use the exported legacy API plus OpenSSL directly), but you may want that addressed separately — happy to open an issue or a small PR if useful.

@Sewci0

Sewci0 commented Aug 4, 2026

Copy link
Copy Markdown
Author

@val-ms everything's addressed

@Sewci0
Sewci0 requested a review from jhumlick August 5, 2026 00:12
Preserve existing libclamav error codes when digest acquisition fails, and emit diagnostics only after releasing the shared digest-cache mutex so application callbacks may safely re-enter hashing.

Require MD5, SHA-1, and SHA-256 to remain available through the private non-FIPS context. Add deterministic Linux failure injection for provider-fetch and library-context allocation failures, including Valgrind coverage for bounded failure behavior.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fc8f0fb6a2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread unit_tests/check_clamav.c
@val-ms

val-ms commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

@Sewci0 Our testing indicates that this leak is reached only when the FIPS environment cannot provide MD5 through ClamAV’s separate non-FIPS OpenSSL context—typically because the default provider is absent, cannot be loaded, or is affected by provider configuration such as OPENSSL_MODULES.

A correctly configured FIPS installation should permit ClamAV to use the FIPS provider for approved algorithms while obtaining MD5 and SHA-1 from the default provider for signature matching and related hashing. This patch prevents memory growth when digest creation fails, but it does not restore unavailable algorithms; the underlying OpenSSL provider configuration should still be corrected so ClamAV’s MD5- and SHA-1-based features function properly.

@val-ms
val-ms requested review from jhumlick and removed request for jhumlick August 5, 2026 17:36
@val-ms

val-ms commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

@codex review — The latest review feedback has been addressed. Please re-review the current head.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: fc8f0fb6a2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Sewci0

Sewci0 commented Aug 11, 2026

Copy link
Copy Markdown
Author

Agreed, and I don't want that lost: this patch bounds the failure, it doesn't repair it. Where the private-context fetch fails, MD5/SHA-1 signature matching stays broken — it just stops consuming memory without bound. Graceful degradation, not a fix.

One refinement on the mechanism, though, because it changes where an operator should be told to look.

OPENSSL_MODULES can't withhold MD5 from that context

OSSL_LIB_CTX_new() creates a context with no config read and no providers activated, so the first EVP_MD_fetch() reaches the default provider through implicit fallback activation — and that provider is compiled into libcrypto rather than loaded from the module directory:

$ ls /usr/lib/aarch64-linux-gnu/ossl-modules/
legacy.so          # no default.so — the default provider is built in

Probing the exact call libclamav makes, on Debian 12 / OpenSSL 3.0.20:

condition EVP_MD_fetch(fresh_ctx, "md5", "-fips")
baseline OK — resolves to provider=default
OPENSSL_MODULES=/nonexistent OK — resolves to provider=default
explicit OSSL_PROVIDER_load(ctx, "default") OK — resolves to provider=default

And as noted earlier in the thread, OSSL_LIB_CTX_new() doesn't read OPENSSL_CONF either — I couldn't get a config-only change to reach the bypass context at all.

So on a stock OpenSSL 3 build, neither the module search path nor openssl.cnf is reachable from ClamAV's private context. Where the fetch nonetheless fails, the enforcement has to be happening below the library-context layer — a libcrypto applying FIPS policy globally rather than per-context. That would be a property of a particular distro FIPS build rather than of the operator's configuration, which matters here: "correct your provider configuration" isn't actionable if the build doesn't expose a knob for it.

I can't confirm what Canonical's 22.04 FIPS build does from here — no FIPS provider on my test hosts. Happy to run a minimal probe on the affected host and report back; that's what settles whether the right guidance is "fix your configuration" or "this build cannot supply MD5 to a private context at all".

Two things that follow from your position

Both argue in the same direction you are, not against it. If ClamAV requires the default provider inside its private context — which test_hash_md5_sha1 now asserts outright — it seems worth making that requirement explicit rather than implicit:

  1. Load the provider explicitly. One OSSL_PROVIDER_load(nonfips_libctx, "default") instead of relying on fallback activation. It gives a single checkable failure point, and on a host where fallback activation is what's being suppressed it may restore MD5 rather than merely failing cleanly. Worth testing on an affected host before assuming either way.

  2. Say what actually went wrong. Today the bypass fetch failure is silent in crypto.c. Through the legacy API the operator sees only cli_scan_fmap: Error initializing md5 hash context (libclamav/matcher.c:1264); through the _ex API it becomes CL_EARGUnsupported hash algorithm: md5, which points at the algorithm name rather than at provider configuration. If the correct remedy is to fix the OpenSSL provider setup, ClamAV should be what tells the operator so — otherwise there's no path from the symptom to that conclusion.

Neither is release-blocking; happy to open them separately once 1.4.6 and 1.5.4 are out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants