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.chainid means it's valid on any fork.
  • A single chainId implementation 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.