Skip to content

ABI Hash Collisions

ABI hash collisions are a vulnerability of the Application Binary Interface encoding format, which smart contracts use to encode and decode the calldata sent to functions. Developers also reach for it to encode and decode custom parameters. Two versions exist, abi.encode and abi.encodePacked. The former is safer but produces a significantly larger result, raising the gas cost, especially when the result is stored. The latter therefore sees wider usage, at the cost of a higher likelihood of hash collisions when dynamic variables are packed together. Such a hash often serves as a storage key or sits inside a signed payload.

The vulnerability arises when a hash is computed over packed ABI-encoded data containing multiple variable-length arguments. Shifting data across those arguments alters the semantics of the payload while leaving the hash unchanged. That property triggers collisions in the Eternal Storage pattern, alters the meaning of signatures, and produces collisions where the hash is used as a mapping key.

A naive implementation of a royalty registry demonstrates the last case:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
pragma solidity ^0.8.17;

contract RoyaltyRegistry {
    uint256 constant regularPayout = 0.1 ether;
    uint256 constant premiumPayout = 1 ether;
    mapping (bytes32 => bool) allowedPayouts;

    function claimRewards(address[] calldata privileged, address[] calldata regular) external {
        // @audit two variable-length arrays packed side by side
        bytes32 payoutKey = keccak256(abi.encodePacked(privileged, regular));
        require(allowedPayouts[payoutKey], "Unauthorized claim");
        allowedPayouts[payoutKey] = false;
        _payout(privileged, premiumPayout);
        _payout(regular, regularPayout);
    }

    function _payout(address[] calldata users, uint256 reward) internal {
        for(uint i = 0; i < users.length;) {
            (bool success, ) = users[i].call{value: reward}("");
            if (!success) {
                // more code handling pull payment
            }
            unchecked {
                ++i;
            }
        }
    }
}

The system sorts users into regular and premium tiers. Royalties are disbursed per team, so multiple regular and premium users are grouped together. An administrator adds the groups to the allowedPayouts mapping to mark them eligible for a reward claim, and anyone can then trigger claimRewards to initiate a payout that transfers ETH to team members according to their entitled amount.

Because the team member data structures are ABI-encoded and hashed into the access key, and because the two variable-length parameters are encoded side by side, an attacker can trigger a hash collision. The following two hashes are equivalent:

1
2
3
hash1 = keccak256(abi.encodePacked([addr1], [addr2, addr3]));
hash2 = keccak256(abi.encodePacked([addr1, addr2], [addr3]));
require(hash1 == hash2);

When calling claimRewards, regular users can therefore add themselves to the privileged array and receive a larger payout than warranted, siphoning funds from the system.