Worst-case impact. HashPubKey()'s output isn't just a hash — it's the actual TLS trust-on-first-use identity: SavePublicKey() writes trusted keys to ppkeys/<user>-<digest>.pub, and lastseen maps IP → trusted digest (Address2Hostkey()). When the digest silently collapses to an all-zero constant instead of erroring, every key hashed during the failure window collapses to the same identity, not merely a bad one.
I checked SavePublicKey(): it refuses to overwrite a file that already exists for a given identity ("already exists, not rewriting"), so this isn't a silent-overwrite bug — it's first-come-first-served. Whichever key gets trusted first under the all-zero identity occupies that slot permanently; any different key that later also hashes to zero (guaranteed, since it's a constant rather than a rare collision) will be looked up under that same slot and treated as a match for it. During a live failure window this means a host trusted first under the collided identity becomes indistinguishable from any other host whose key also failed to hash the same way — and since neither HashFile() nor HashPubKey() logged anything before this fix, there is no forensic trail explaining why two different keys were ever treated as the same identity.
Caveat, so this isn't overstated: the demonstrated trigger (CryptoDeInitialize() unloading OpenSSL 3's default provider, then something hashes again afterward) is an internal call-ordering condition, not something a remote attacker can force directly today. So this is a genuine silent integrity hole in the trust model, not a demonstrated remote exploit — the severity is "this should never have been able to fail silently and indistinguishably," not "here is a working attack chain."
Component: libutils · Affects: master (0c0620d), present since 2019 · Severity: normal
I'd normally file this against project CFE in the Jira tracker, but I can't
right now — both routes are broken on my end: the API returns 401 on
/rest/api/3/myself with a well-formed token, and the web UI shows me as
logged in (I see a Create button) but any actual action behaves as if I'm
logged out. Opening this here instead so it isn't stuck waiting on that; happy
to move/link it into Jira myself once access is sorted, or if a maintainer
can do that from your end in the meantime, much appreciated.
libntech's libutils/hash.c calls EVP_DigestInit() or EVP_DigestInit_ex() in six
places. Three of them do not handle failure, each in a different way, and none of
the three logs anything:
-
HashFile() (via the static HashFile_Stream()) and HashPubKey() zero their
output digest up front and fill it in only on success. On failure the caller
receives an all-zero digest that is indistinguishable from a real one. Both
functions return void, so the digest buffer is the only channel and it
carries no success indication.
-
HashNew() ignores the return value entirely and then calls EVP_DigestUpdate()
and EVP_DigestFinal_ex() on an uninitialized context. It returns a non-NULL
Hash whose digest is meaningless.
The affected code, on master (0c0620d):
This is an oversight rather than a decision. All three functions arrived in
libntech in the same commit, f277970 (2019-10-03, "Added hash functions from
libpromises"). At that commit HashString() already had the else-branch that logs
LOG_LEVEL_ERR in exactly this case, while HashFile_Stream() and HashPubKey() went
straight to freeing the context with no else at all. The asymmetry has been there
for six years and is still present on master.
The three siblings in the same file that already handle it correctly:
Impact. The most serious of the three is HashPubKey(), which computes the digest
of a host's public key. In cfengine/core it feeds libpromises/lastseen.c,
libpromises/crypto.c, libenv/sysinfo.c, cf-execd/cf-execd-runner.c, and is
included by libcfnet/tls_generic.c and libcfnet/client_protocol.c. A public key
digest that silently becomes a constant is a host identity that collides across
every host that hits the failure. HashFile() has 12 call sites in core.
Reachability. This is not theoretical. On OpenSSL 3, CryptoDeInitialize() unloads
the default provider, after which every subsequent EVP_DigestInit() fails.
Anything that hashes after that point is silently affected. That is how this was
found.
Reproduction. Call any of the three functions, then call CryptoDeInitialize(),
then call it again. Observed on the second call, against unmodified master:
HashFile -> all-zero digest, nothing logged
HashPubKey -> all-zero digest, nothing logged
HashNew -> non-NULL Hash, meaningless digest, nothing logged
Proposed fix. Each of the three is modelled on its own in-file sibling above:
HashFile_Stream() and HashPubKey() gain the else-branch that HashString() already
has, and HashNew() logs and returns NULL as HashNewFromDescriptor() already does.
HashNew() already returns NULL on four other paths and has no callers in
cfengine/core, so failing closed there breaks nothing. Modelling each fix on its
own sibling keeps the change an oversight repair rather than a new opinion.
21 insertions, 3 deletions, one file. Builds clean; libntech's hash_test passes
(6 tests).
Making failure detectable by the callers of HashFile() and HashPubKey(), rather
than merely visible in the log, would mean changing the return types of two void
functions with callers across both repositories, and is deliberately left out.
No unit test is included: forcing EVP_DigestInit() to fail requires
CryptoDeInitialize(), which lives in cfengine/core's libpromises and is not
available to libntech's own tests, and a libntech-level test could only assert
the all-zero digest that is returned either way.
Pull request: now open upstream at
#291 (review thread and diff
also available on my fork: djbclark#1)
Diff against upstream master:
master...djbclark:libntech:silent-digest-failure
Commit:
djbclark@dc85a6f
Worst-case impact.
HashPubKey()'s output isn't just a hash — it's the actual TLS trust-on-first-use identity:SavePublicKey()writes trusted keys toppkeys/<user>-<digest>.pub, andlastseenmaps IP → trusted digest (Address2Hostkey()). When the digest silently collapses to an all-zero constant instead of erroring, every key hashed during the failure window collapses to the same identity, not merely a bad one.I checked
SavePublicKey(): it refuses to overwrite a file that already exists for a given identity ("already exists, not rewriting"), so this isn't a silent-overwrite bug — it's first-come-first-served. Whichever key gets trusted first under the all-zero identity occupies that slot permanently; any different key that later also hashes to zero (guaranteed, since it's a constant rather than a rare collision) will be looked up under that same slot and treated as a match for it. During a live failure window this means a host trusted first under the collided identity becomes indistinguishable from any other host whose key also failed to hash the same way — and since neitherHashFile()norHashPubKey()logged anything before this fix, there is no forensic trail explaining why two different keys were ever treated as the same identity.Caveat, so this isn't overstated: the demonstrated trigger (
CryptoDeInitialize()unloading OpenSSL 3's default provider, then something hashes again afterward) is an internal call-ordering condition, not something a remote attacker can force directly today. So this is a genuine silent integrity hole in the trust model, not a demonstrated remote exploit — the severity is "this should never have been able to fail silently and indistinguishably," not "here is a working attack chain."Component: libutils · Affects: master (
0c0620d), present since 2019 · Severity: normalI'd normally file this against project CFE in the Jira tracker, but I can't
right now — both routes are broken on my end: the API returns 401 on
/rest/api/3/myselfwith a well-formed token, and the web UI shows me aslogged in (I see a Create button) but any actual action behaves as if I'm
logged out. Opening this here instead so it isn't stuck waiting on that; happy
to move/link it into Jira myself once access is sorted, or if a maintainer
can do that from your end in the meantime, much appreciated.
libntech'slibutils/hash.ccallsEVP_DigestInit()orEVP_DigestInit_ex()in sixplaces. Three of them do not handle failure, each in a different way, and none of
the three logs anything:
HashFile()(via the staticHashFile_Stream()) andHashPubKey()zero theiroutput digest up front and fill it in only on success. On failure the caller
receives an all-zero digest that is indistinguishable from a real one. Both
functions return
void, so the digest buffer is the only channel and itcarries no success indication.
HashNew()ignores the return value entirely and then callsEVP_DigestUpdate()and
EVP_DigestFinal_ex()on an uninitialized context. It returns a non-NULLHashwhose digest is meaningless.The affected code, on master (
0c0620d):HashNewHashFile_StreamHashPubKeyThis is an oversight rather than a decision. All three functions arrived in
libntech in the same commit,
f277970(2019-10-03, "Added hash functions fromlibpromises"). At that commit
HashString()already had theelse-branch that logsLOG_LEVEL_ERRin exactly this case, whileHashFile_Stream()andHashPubKey()wentstraight to freeing the context with no
elseat all. The asymmetry has been therefor six years and is still present on master.
The three siblings in the same file that already handle it correctly:
HashStringHashNewFromDescriptorHashNewFromKeyImpact. The most serious of the three is
HashPubKey(), which computes the digestof a host's public key. In
cfengine/coreit feedslibpromises/lastseen.c,libpromises/crypto.c,libenv/sysinfo.c,cf-execd/cf-execd-runner.c, and isincluded by
libcfnet/tls_generic.candlibcfnet/client_protocol.c. A public keydigest that silently becomes a constant is a host identity that collides across
every host that hits the failure.
HashFile()has 12 call sites in core.Reachability. This is not theoretical. On OpenSSL 3,
CryptoDeInitialize()unloadsthe default provider, after which every subsequent
EVP_DigestInit()fails.Anything that hashes after that point is silently affected. That is how this was
found.
Reproduction. Call any of the three functions, then call
CryptoDeInitialize(),then call it again. Observed on the second call, against unmodified master:
Proposed fix. Each of the three is modelled on its own in-file sibling above:
HashFile_Stream()andHashPubKey()gain theelse-branch thatHashString()alreadyhas, and
HashNew()logs and returnsNULLasHashNewFromDescriptor()already does.HashNew()already returnsNULLon four other paths and has no callers incfengine/core, so failing closed there breaks nothing. Modelling each fix on itsown sibling keeps the change an oversight repair rather than a new opinion.
21 insertions, 3 deletions, one file. Builds clean; libntech's
hash_testpasses(6 tests).
Making failure detectable by the callers of
HashFile()andHashPubKey(), ratherthan merely visible in the log, would mean changing the return types of two
voidfunctions with callers across both repositories, and is deliberately left out.
No unit test is included: forcing
EVP_DigestInit()to fail requiresCryptoDeInitialize(), which lives incfengine/core'slibpromisesand is notavailable to libntech's own tests, and a libntech-level test could only assert
the all-zero digest that is returned either way.
Pull request: now open upstream at
#291 (review thread and diff
also available on my fork: djbclark#1)
Diff against upstream master:
master...djbclark:libntech:silent-digest-failure
Commit:
djbclark@dc85a6f