Skip to content

ABI Hash Collisions

ABI Hash Collisions refer to a vulnerability type of the Application Binary Interface (ABI) encoding format, which smart contracts use to encode and decode calldata sent to functions. However, it can also be used by smart contract developers to encode and decode custom parameters. Two versions exist: abi.encode and abi.encodePacked. While more secure, the former results in a significantly larger result data size, thus raising the gas cost, especially when storing it. As a result, the latter (abi.encodePacked) sees wider usage, albeit with increased risk due to the higher likelihood of hash collisions when dynamic variables are packed together. The hash is often used as a storage key or within a signed payload, with potential security ramifications.

This vulnerability arises when a hash calculation is used with packed ABI-encoded data that includes multiple variable-length arguments. Despite the hash remaining the same, shifting data across arguments can alter the payload semantics. This can trigger hash collisions in the Eternal Storage pattern, alter the meaning of signatures, and result in collisions when used as a mapping key.

A naive implementation of a royalty registry demonstrates the latter 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 {
        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;
            }
        }
    }
}

In this system, users are categorized into regular and premium ones. Royalties are disbursed by teams. Thus multiple regular and premium users are grouped together. An administrator adds the groups to the allowedPayouts mapping, indicating their eligibility for reward claims. The claimRewards function can then be triggered by anyone to initiate a payout where the contract transfers the ETH to team members according to their entitled amount.

Given that the team member data structures are ABI-encoded and hashed for their access key, a hash collision can be triggered by an attacker due to variable-length parameters being encoded side-by-side. The nature of this attack lies in the equivalence of the following hashes:

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

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