Hash Collisions (abi.encodePacked)
SCWE-074: Hash Collisions with Multiple Variable Length Arguments
Theory
In Solidity, abi.encodePacked() concatenates arguments without padding or type information, creating a compact byte representation. Unlike abi.encode(), which adds 32-byte padding and type metadata, abi.encodePacked() produces ambiguous encodings when combining multiple dynamic-length types.
The Collision Mechanism
When multiple dynamic-length arguments (strings, bytes, dynamic arrays) are packed together, their boundaries become indistinguishable. This allows different input combinations to produce identical hash outputs.
Example collision scenario:
solidity
abi.encodePacked("aa", "ab") // Results in: 0x61616162
abi.encodePacked("aa", "a", "b") // Results in: 0x61616162
abi.encodePacked("a", "aab") // Results in: 0x61616162All three combinations produce the same bytes, and consequently the same keccak256 hash. This occurs because abi.encodePacked() simply concatenates the UTF-8 representations without length delimiters.
Storage Impact
Solidity mappings using keccak256(abi.encodePacked(...)) as keys are particularly vulnerable. When collision-prone encodings serve as unique identifiers, attackers can:
- Access unauthorized data by crafting colliding keys
- Overwrite existing entries with malicious values
- Bypass authentication mechanisms relying on hash-based lookups
- Manipulate signature verification systems
Why abi.encode() Prevents Collisions
abi.encode() adds explicit length information and 32-byte padding for each argument, making collisions computationally infeasible:
solidity
abi.encode("aa", "ab")
// 0x0000...0020 (offset to first string)
// 0000...0040 (offset to second string)
// 0000...0002 (length of "aa")
// 6161...0000 (padded "aa")
// 0000...0002 (length of "ab")
// 6162...0000 (padded "ab")
abi.encode("aaa", "b") // Produces completely different encodingPractice
When auditing smart contracts, identify any use of abi.encodePacked() combined with keccak256() for generating mapping keys, signatures, or unique identifiers. Focus on functions where users can control multiple dynamic-length arguments.
High-Risk Patterns
Critical vulnerabilities occur when:
- Multiple
stringorbytesparameters are passed toabi.encodePacked() - Dynamic arrays of variable-length types are concatenated
- User-controlled inputs are hashed without fixed-length separators
- Signature schemes rely on
encodePackedfor message construction - Access control or authentication uses collision-prone hash keys
Safe usage scenarios:
- Single dynamic-length argument:
abi.encodePacked(string) - Only fixed-size types:
abi.encodePacked(uint256, address, bytes32) - Mixed with proper separators:
abi.encodePacked(string1, uint256, string2)
Vulnerable Contract Example
Exemple - Decentralized registry system
Consider a decentralized registry system where users can claim domain names by combining a username with a top-level domain. The contract uses a mapping to track ownership, with the key generated by hashing the concatenated strings. This design appears reasonable at first glance since each user should have a unique combination of name and domain.
pragma solidity ^0.8.0;
contract VulnerableRegistry {
mapping(bytes32 => address) public records;
// Vulnerable: Two dynamic-length strings
function register(string memory name, string memory domain) public {
bytes32 key = keccak256(abi.encodePacked(name, domain));
require(records[key] == address(0), "Already registered");
records[key] = msg.sender;
}
function getOwner(string memory name, string memory domain) public view returns (address) {
bytes32 key = keccak256(abi.encodePacked(name, domain));
return records[key];
}
}The vulnerability lies in the boundary ambiguity between the two string parameters. When Alice attempts to register ("alice", "example.com"), the contract concatenates these into "aliceexample.com" before hashing.
However, an attacker monitoring the mempool can front-run her transaction by registering ("aliceexample", ".com") instead, which produces the identical concatenated string and therefore the same hash.
Scenario: Alice registers ("alice", "example.com")
# Alice's legitimate registration
cast send $CONTRACT_ADDRESS \
'register(string,string)' \
"alice" \
"example.com" \
--rpc-url $RPC_URL \
--private-key $ALICE_PKAttack: Bob finds a collision and registers first
# Bob registers with colliding inputs BEFORE Alice
## Both calls produce the same hash:
## keccak256("aliceexample.com") == keccak256("aliceexample.com")
cast send $CONTRACT_ADDRESS \
'register(string,string)' \
"aliceexample" \
".com" \
--rpc-url $RPC_URL \
--private-key $BOB_PKWhen Alice attempts registration, it fails because Bob already owns that hash key. Bob effectively squatted on Alice's intended registration through collision.
Below is a small proof of concept to find collisions:
pragma solidity ^0.8.0;
contract CollisionDemo {
function demonstrateCollision() public pure returns (bool) {
bytes32 hash1 = keccak256(abi.encodePacked("aa", "bb"));
bytes32 hash2 = keccak256(abi.encodePacked("a", "abb"));
bytes32 hash3 = keccak256(abi.encodePacked("aab", "b"));
return (hash1 == hash2) && (hash2 == hash3); // Returns true
}
function showEncodings() public pure returns (bytes memory, bytes memory) {
return (
abi.encodePacked("aa", "bb"), // 0x61616262
abi.encodePacked("a", "abb") // 0x61616262
);
}
}Exemple 2 - Signature Collision Attack
Hash collisions in signature verification can enable privilege escalation:
pragma solidity ^0.8.0;
contract VulnerableAuth {
address public admin;
constructor() {
admin = msg.sender;
}
// Vulnerable signature verification
function executeAction(
string memory action,
string memory target,
bytes memory signature
) public {
bytes32 messageHash = keccak256(abi.encodePacked(action, target));
address signer = recoverSigner(messageHash, signature);
require(signer == admin, "Unauthorized");
// Execute privileged action
}
function recoverSigner(bytes32 hash, bytes memory sig) internal pure returns (address) {
// ECDSA recovery logic
// ...
}
}An attacker can craft colliding (action, target) pairs to reuse a valid signature for unauthorized operations:
- Admin signs:
("transfer", "100") - Attacker submits:
("transfe", "r100")with the same signature