Mappings keyed by keccak256 hashes are everywhere in Solidity. Replay guards,
authorization flags, processed-message tracking, session state. The assumption baked into
every one of them is the same: distinct inputs produce distinct keys. That assumption breaks
before the cryptography does.
keccak256 itself is collision-resistant. The vulnerability lives upstream, in
how the input is constructed. There are three distinct ways the construction goes wrong.
Failure mode 1: abi.encodePacked with multiple dynamic types
abi.encodePacked strips padding from dynamic types and concatenates them raw.
No length prefixes. No delimiters. No argument boundaries. Feed it two strings and the same
bytes come out regardless of where you split them:
abi.encodePacked("a", "bc") == abi.encodePacked("ab", "c")
// identical bytes: 0x616263
Different inputs. One hash. One shared slot in your mapping. That's a collision and
keccak256 had nothing to do with it. This is documented in the Solidity
specification. It is not an edge case that only manifests at scale. It manifests the moment
a second caller thinks creatively about the inputs.
The failure compounds in multi-array authorization checks. A contract validates a signed
payload by hashing keccak256(abi.encodePacked(admins, regularUsers)) where
both are address arrays. An attacker moves addresses between the two arrays. The concatenated
bytes stay identical. Hash matches. Signature validates.
Admin list [A, B] with regular list [C, D] produces the same
packed output as admin list [A] with regular list [B, C, D].
This is not theoretical. The NFTPort Factory contract, audited by Sherlock
in October 2022, had exactly this pattern at three call sites. All three used
abi.encodePacked with multiple dynamic arguments to generate hashes validated
against admin signatures. An attacker could construct colliding inputs that passed the
signedOnly modifier with any signature previously captured from a legitimate call.
Failure mode 2: A hash that doesn't encode what it's authorizing
This failure doesn't require encodePacked at all. It requires a hash that omits
context.
A contract sets processedMessages[hash] = true to prevent replay. The hash
derives from (sender, amount) only - no operation type, no nonce, no context.
A deposit and a withdrawal with the same sender and amount share the same key. One sets the
flag. The other finds it already set and either reverts or silently succeeds against stale
state - blocking legitimate operations or permitting unauthorized ones.
Failure mode 3: EIP-712 with an incomplete domain separator
EIP-712 was designed to solve this for off-chain signatures. It binds a signature to a specific contract on a specific chain via a domain separator. The protection only holds when all components are present.
- Missing
address(this)means a signature valid for contract A is valid for contract B with the same name. - Missing
block.chainidmeans it's valid on any fork. - A single
chainIdimplementation bug in 2024 affected over 40 wallet vendors simultaneously.
EIP-712 also has no built-in replay protection. There is no nonce in the spec. Developers add it manually, which means a correctly implemented domain separator with a missing per-message nonce is still replayable within the same domain.
The permanent damage
Mapping entries in Solidity don't expire. delete is opt-in. A flag set once
stays set until code explicitly clears it. In practice, deletion rarely happens. Stale
authorization flags linger after sessions end. Replay guards stay raised. Processed-message
markers block the retries they were meant to permit.
A one-time-use voucher system sets
usedVouchers[keccak256(abi.encode(user, amount))] = true after redemption.
The hash doesn't include a voucher ID or expiry. A user returns with a new voucher for the
same amount. Same hash. Contract treats it as already redeemed. The legitimate voucher is
permanently blocked, with no path to recovery.
The fix
Use abi.encode instead of abi.encodePacked whenever hashing
multiple values. abi.encode pads every argument to 32 bytes. Boundaries are
explicit, and shifting values between arguments changes the output.
Always include an operation type tag in the preimage. A deposit and a withdrawal should never share a hash even if sender and amount are identical.
For off-chain signatures, use EIP-712 - but use it completely. Include
address(this), block.chainid, a typed struct hash, and a
per-message nonce. OpenZeppelin's implementation handles the domain separator correctly;
rolling your own requires care at every field.
Delete mapping entries when the state they represent expires. Stale flags are not free storage. They are a permanent footgun.
The rule: abi.encodePacked is a byte concatenator. If you
are building keys with it, you are betting nobody finds the other input that produces the
same bytes. That bet gets called. The cryptography is fine. The construction around it isn't.