MVF-2026-0005 — two strings sharing one 64-bit digest were one hash entry
A hash key was a type and a 64-bit FNV-1a digest, and nothing else. Two different strings whose digests collide were therefore the same key: the second write replaced the first, the table's length…
| Identifier | MVF-2026-0005 | | Internal row | M26-EVL-003 | | Severity | S2, priority P1 | | Affected | every release: 1.0.0, 1.0.1, 2.0.0, 2.0.1, 2.1.0, 2.2.0, 2.3.0, 2.4.0 and 2.5.0 | | Not affected | none; the defect is as old as the hash type | | Fixed in | 2.6.0 | | Reported | internally; Stage B1 audit, confirmed 2026-09-24 | | Published | 2026-10-05 | | CVE / GHSA | none assigned | | CVSS:3.1 | CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:N | | CVSS:4.0 | CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:P/VC:L/VI:H/VA:N/SC:N/SI:N/SA:N |
Summary#
A hash key was a type and a 64-bit FNV-1a digest, and nothing else. Two different strings whose digests collide were therefore the same key: the second write replaced the first, the table's length did not grow, and a lookup of either string returned whichever had been written last. Nothing reported that it had happened. Collisions in a 64-bit non-cryptographic digest are findable in seconds on a laptop, so this was reachable by anyone who could choose two of the strings a script used as keys.
This is the oldest defect in this register: the string half is present unchanged at every tag from 1.0.0 to 2.5.0.
Impact#
A hash keyed by evidence-derived strings — a count per filename, a verdict per artefact, a set of seen hashes — could silently merge two distinct entries into one. The symptoms are a count that is short, a dedup set that is missing a member, and a lookup that answers with another key's value. Nothing in any affected release records that a merge happened, which is why this is rated for integrity rather than for disclosure: the wrong answer is indistinguishable from the right one after the fact.
What must not be read into this#
The demonstrated attack requires the adversary to plant both colliding
strings. The collision in the fixture was found by a birthday search, which
produces two values both chosen by the searcher — both members of the pair are
16-character hex strings, which is what such a search emits, not plausible file
names. Nothing in this repository demonstrates finding a second value that
collides with a key an operator already had. So this is not evidence that
clean.exe in an allow-list, or a verdict already recorded against a known file,
could be made to collide with a name of an attacker's choosing.
The symmetric caution applies, and is the reason the fix was not deferred: FNV-1a-64's round function is an invertible multiply-and-xor, so a targeted second preimage may well be cheap. The honest position is that this repository shows the birthday case, shows nothing about the targeted case, and the fix makes the question moot.
The merge was not always silent. In 2.5.0 the structured-data parsers build
their hashes through a path that refuses a duplicate key, so a document carrying
two colliding keys made the whole parse fail with a misleading duplicate key
error naming only one of them, rather than merging. unique, contains and
index_of compare values exactly and were never affected.
The buffer half is 2.5.0 only. object/bytesObj.go does not exist before
2.5.0, so "buffers were keyed the same way" is true of exactly one release.
Details#
A hash is a Go map whose key is a small struct, and that struct had two fields: a type tag and a 64-bit value. A string's key put the FNV-1a-64 digest of the string into the value field and carried nothing else; from 2.5.0 a buffer's key did the same over its bytes. Go's struct equality on those two fields was the entire test for "the same key".
Every hash site in the tree indexes that map by the computed key and then uses
the entry it finds without comparing the stored key to the one it looked up —
in the evaluator's index expression, in the VM's index opcode, in both
index-assignment paths, in the VM's hash construction, and in 2.5.0 also in
has_key, get and an error's related-value hash. So a collision was not a
near-miss that something later caught; it was one entry.
The pair in the fixture was confirmed arithmetically rather than taken on trust:
FNV-1a-64 over e38154aa4dee0878 and over c4272bece7a8573e both give
c5844c23d3d0fabc.
The fix#
The key struct gains a third field that carries the key's value instead of a digest: a string's key holds the string whole, sharing its backing array so nothing is copied, and a buffer's key holds its SHA-256 rather than the buffer — because a Go map key is an immutable string that nothing can zero afterwards, and a buffer may be classified plaintext.
Because the struct is the map key, Go's existing equality now compares all three fields, so the change repairs every insert and every lookup in both engines without editing a single call site. The type tag is still part of the key, so a string and a buffer holding the same bytes remain different keys.
No artefact or wire format changes. No .mu file and no serialised stream
carries a hash key: a hash literal compiles to an opcode over compiled pairs, and
an error's related values are a string-keyed map converted on demand. Nothing
needs recompiling.
Fixture#
object/hash_key_test.go, TestTwoKeysWithOneDigestAreTwoKeys, and
parity/hash_collision_parity_test.go,
TestTwoNamesWithOneDigestAreTwoKeysInBothEngines. The second is the one that
matters structurally: the two engines index the same map by the same key, so the
parity fixture is what stops one of them being repaired and the other left.
Workarounds#
Partial only, and a scripting discipline rather than a setting. On an
affected release, do not let an evidence-derived string be a hash key where a
merge would matter. Hold the values in an array and use unique, contains and
index_of, which compare values exactly and were never affected. The cost is a
linear scan instead of a map lookup. Keys that are integers, floats or booleans
were always exact, because the key's value field carries the bits rather than a
digest, so re-keying a table by an exact integer id is also safe.
What that does not cover, and each of these is a real gap:
- Hashes the runtime builds for you, where the script never chooses the keys — the header hash an HTTP call returns, per-level rule counts, an error's related-values hash, and every other place the runtime assembles a hash.
- The structured-data parsers. Avoiding hash literals changes nothing,
because
json_parseand the CBOR, YAML, TOML, MessagePack, CSV and XML paths build the hash themselves; a colliding key pair aborts the whole parse with aduplicate keyerror naming only one of the two. - Damage already done. Nothing records that a merge happened, so a count, a dedup set or a verdict table written by an affected build cannot be audited after the fact. Re-running the script under a fixed build is the only way to find out.
- Scripts you did not write. There is no flag, option or configuration in any affected release that changes how a key is computed, and Mutant takes no configuration from environment variables at all, so this cannot be applied from outside the script.
Upgrading to 2.6.0 is the only complete remedy. There is no patch release for the 2.5 line.
What changes for programs that worked before#
Two strings that used to be one key are now two. A program that depended on the merge — which it could only have done by accident — will see a table one entry longer and a lookup that answers with the value actually stored under that key. A hash literal that names two colliding keys no longer silently keeps the last; each is its own entry. No spelling, no type and no artefact format changes.
Credit#
Found by this project's own 2.6.0 security review, Stage B1, on 2026-09-24, and confirmed in Stage B4 the same day. No external reporter.
References#
- The entry in CHANGELOG.md under 2.6.0, Security.
- SECURITY.md for the severity scale.
- The advisory register.