⚠️ERC-2771 Delegatecall Vulnerability

Make sure your contracts are not affected by the recently disclosed vulnerability

Please read the following resources from OpenZeppelin and ThirdWeb explaining the vulnerability:

Vulnerability explained

ERC-2771 is a standard enabling contracts to authenticate users during transaction relaying. Before delving into the security risks of its implementation, it is crucial to understand the mechanics of the ERC-2771 flow.

ERC-2771 Overview

User Request Signing

  • The user signs their request and incorporates this signature into the payload.

Relay Contract Verification

  • The relay contract validates the signature and appends the user's 20-byte address to the end of the calldata.

Target Contract Decoding

  • The target contract decodes the user address by extracting the last 20 bytes from the calldata, but only when msg.sender is the relay contract, known as the Trusted Forwarder.

  • Decoding is done using assembly for efficiency, as shown in the following code snippet:

// Decoding the user Address
function _msgSender() internal view virtual override returns (address sender) {
    if (isTrustedForwarder(msg.sender)) {
        // The assembly code is more direct than the Solidity version using `abi.decode`.
        assembly {
            sender := shr(96, calldataload(sub(calldatasize(), 20)))
        }
    } else {
        return super._msgSender();
    }
}

Risks of delegatecall

Context Preservation in delegatecall

  • When Contract A invokes Contract B using delegatecall(), msg.sender in Contract B remains the original caller, as delegatecall() preserves the caller's context.

Address Extraction in ERC-2771

  • As outlined above, extracting the original user address involves verifying that msg.sender is the Trusted Forwarder, then retrieving the user address from the final 20 bytes of callData.

ERC-2771 Relayer Specifics

  • If an ERC-2771 Relayer is employed and the target method uses delegatecall() to its own address (address(this).delegatecall(...)), the Trusted Forwarder check will always pass, as msg.sender will consistently be the Gelato Relay Contract.

  • In scenarios where the target method modifies the calldata, it becomes uncertain whether the last 20 bytes accurately represent the original user when _msgSender() is invoked.

🚨 If you're implementing delegatecall() in conjunction with ERC-2771, please reach out to us for assistance. We'll help ensure that your implementation is robust and secure.

Vulnerability conditions

The vulnerability described arises when all three of the following conditions are met in a smart contract. It's crucial to avoid these conditions concurrently.

Avoid the following conditions in the same smart contract:

  1. Implementation of ERC2771Context or assumptions on data from the trusted forwarder: the contract either implements ERC2771Context or operates under the assumption that data from the trusted forwarder will be appended to and subsequently extracted from the calldata.

  2. Use of delegatecall to Self-Contract: the contract uses delegatecall to call itself, typically indicated by address(this).delegatecall(...).

  3. Calldata manipulation: situations involving the manipulation of calldata, common in functions like multicall.

🚨 Avoid multicall in combination with ERC-2771

The vulnerability is evident in a typical multicall function, structured as follows:

function multicall(bytes[] calldata data) external returns(bytes[] memory results) {
    results = new bytes[](data.length);
    for(uint i = 0; i < data.length; i++) {
        (bool success, bytes memory result) = address(this).delegatecall(data[i]);
        require(success);
        results[i] = result;
    }
    return results;
}

Vulnerability Mechanism

  • Within the loop, delegateCall() is executed, targeting the contract itself (address(this).delegatecall(data[i]).

  • When _msgSender() is evaluated within this call, it does not return the original user who signed the transaction. Instead, it yields the last 20 bytes of data[i].

Potential for Exploitation

  • A malicious actor could exploit this by appending a victim's address at the end of data[i].

  • As a result, _msgSender() would erroneously identify the victim's address as the validated user who signed the transaction, leading to potential security breaches.

Safe multicall & ERC-2771 implementation

To securely implement multicall in conjunction with ERC-2771, it is recommended to manually append the context to each data[i], as outlined in OpenZeppelin's blog. The approach involves the following steps:

function multicall(bytes[] calldata data) external returns(bytes[] memory results) {
    bytes memory context = msg.sender == _msgSender()
        ? new bytes(0)
        : msg.data[msg.data.length - 20:];
    results = new bytes[](data.length);
    for(uint i = 0; i < data.length; i++) {
        (bool success, bytes memory result) = 
            address(this).delegatecall(bytes.concat(data[i], context));
        require(success);
        results[i] = result;
    }
    return results;
}

Key Points

  • Context Determination: The context is derived by comparing msg.sender and _msgSender(). If they match, no additional context is appended. Otherwise, the last 20 bytes of msg.data are used.

  • Secure Delegatecall: By appending the context to each data[i] before the delegatecall, the function ensures that the original sender's address is correctly interpreted in subsequent calls.

  • Robust Error Handling: The use of require(success) after each delegatecall ensures that any call that fails will halt the execution, maintaining the integrity of the operation.

Last updated