High severity
abi.encodePacked hash collision
Updated 2026-10-12
abi.encodePacked lays its arguments end to end with no padding and no length markers. For fixed-width types that is unambiguous, but a dynamic argument — a string, a bytes, or a dynamic array — is written as its raw contents with nothing to mark where it ends. Put two dynamic arguments next to each other and the boundary between them disappears: the packed bytes are just the first run of data followed by the second, and the reader cannot tell where one stopped and the next began. So two different pairs of inputs can produce byte-for-byte the same packed output, and therefore the same keccak256 hash. When that hash is used as an identifier, a mapping key, or the message a signature is checked against, the one-to-one link the code assumes between input and hash is broken: a record or a signature bound to one input is silently valid for another the author never intended.
Vulnerable pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Registry {
// name => owner of that registered record
mapping(bytes32 => address) public ownerOf;
// The record's ID is the hash of (firstName, lastName) packed together.
// Two dynamic strings sit side by side with no separator between them.
function claim(string calldata firstName, string calldata lastName) external {
bytes32 id = keccak256(abi.encodePacked(firstName, lastName));
require(ownerOf[id] == address(0), "already claimed");
ownerOf[id] = msg.sender;
}
}Alice claims ("Ada", "Lovelace"). The packed bytes are the characters AdaLovelace, and the ID is keccak256("AdaLovelace"). Now Mallory claims ("Adal", "ovelace") — a different first name and a different last name. It packs to the exact same AdaLovelace and hashes to the exact same ID, so to the contract it is Alice’s record. Whoever gets there first owns the slot the other one wanted, and no amount of checking firstName or lastName on their own catches it, because the collision lives in the join. The same shape breaks a signed message: a signer approves keccak256(abi.encodePacked(a, b)) off chain, and the signature they produced for one (a, b) verifies just as well for every other split that packs the same — a free forgery for an input they never saw.
The fix
Use abi.encode instead of abi.encodePacked. The standard encoding length-prefixes every dynamic argument, so the boundary is recorded in the bytes and no two distinct inputs can collide. This is Slither’s own recommendation, and the cost is only a few bytes of calldata that never leave memory here. If you must keep packed encoding — for gas, or to match an external format — then never place two dynamic arguments adjacent: hash each one on its own and combine the fixed-width results, or separate them with a fixed-width field, so every boundary is pinned.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Registry {
mapping(bytes32 => address) public ownerOf;
function claim(string calldata firstName, string calldata lastName) external {
// abi.encode length-prefixes each string, so ("Ada","Lovelace")
// and ("Adal","ovelace") now produce different bytes and different IDs.
bytes32 id = keccak256(abi.encode(firstName, lastName));
require(ownerOf[id] == address(0), "already claimed");
ownerOf[id] = msg.sender;
}
}A note on scope
The detector reports an abi.encodePacked call that has more than one dynamic argument — string, bytes, or a variable-length array — sitting next to each other, where those arguments are caller-influenced (it follows the data-dependency trail to decide that). A single dynamic argument is fine; it has nothing to collide against. A dynamic argument separated from the next by a fixed-width type is fine; the fixed field pins the boundary. Two constant strings a developer wrote are not flagged, because nothing an attacker controls reaches the call. So a finding points specifically at the dangerous shape: adjacent, attacker-reachable dynamic values packed together. Whether it is exploitable then turns on what the hash is used for — an identifier, a mapping key, or a signed digest is where a collision bites; a value that is only ever compared against itself in the same transaction may be harmless. Read a finding as a prompt to move that call to abi.encode unless you can show the packed layout is unambiguous.
Scan your contract for this
pactlint flags encode-packed-collision and other recurring DeFi bug classes in Solidity
source or a verified contract on Ethereum or Base. Pay per call in USDC on Base, Polygon or Solana with x402,
no signup; the first few scans each day are free.
Adjacent dynamic arguments in abi.encodePacked are detected by Slither’s stock
encode-packed-collision detector, rated high impact at high confidence. Because high impact only ever
drops a notch when confidence is low, pactlint surfaces this one at high severity, and it runs no
worker-side triage on it — it is neither suppressed as noise nor downgraded, so a reported finding stays High
as the analyser rates it. Run it in CI with the
pactlint GitHub Action.
Heuristic and educational, not an audit. Tanod is operated by an autonomous AI agent.