Solidity's delegateCall() Unveiled: Risks, Solutions, and Best Coding Practices

In the ever-evolving landscape of blockchain technology, smart contracts are still the backbone of decentralized applications, enabling seamless and trustworthy transactions. Among many functions in Solidity, which is the primary programming language on the Ethereum blockchain, delegateCall() stands out as a powerful tool. In this article, we will embark on a comprehensive exploration of delegateCall(), dissecting its intricacies, uncovering potential pitfalls, and guiding developers through the best practices to ensure the security and integrity of their smart contracts.

Understanding delegateCall()

delegateCall() is a low-level function in Solidity used for invoking functions in other contracts. Unlike a regular function call, delegateCall() allows the called contract to access the context of the calling contract, including its storage and state variables. The advantage of delegatecall is that it enhances composability and code reuse patterns in Ethereum smart contracts. It enables the creation of upgradable contracts, modular contracts, and library contracts, and it can be used for cross-chain communication between different blockchain networks. It also comes with significant security risks that developers need to be aware of.

Identifying Security Concerns

We are going to explore inherent risks associated with delegateCall(). We will dissect real-world contract snippets, examining security concerns such as controlled delegatecall risk, unchecked return values, missing access control modifiers, and the absence of contract existence checks.

Given the contract snippet below try to identify the security flaws in the code before reading further:

pragma solidity 0.8.4;

contract test {

    // Assume other required functionality is correctly implemented

    modifier onlyAdmin {
     // Assume this is correctly implemented
        _;
    }

    function delegate (address addr) external {  addr.delegatecall(abi.encodeWithSignature("setDelay(uint256)"));
    }
}

In this code, the delegate function takes an address addr as an argument. It then performs a delegatecall to the contract at addr, calling the function setDelay(uint256). This delegatecall executes the setDelay function from the contract at addr in the context of the calling contract (the test contract).

The following are security concerns associated with delegateCall() as witnessed in the code snippet above

delegate() may be missing onlyAdmin modifier

In the original code snippet, the delegate function is missing the onlyAdmin modifier. This means that any external caller can call this function, which is a serious security risk. If a malicious actor is able to call this function, they could manipulate the state of your contract in unintended ways. This is especially risky with the delegatecall function, as it allows the caller to execute any function of your contract with the context of your contract, potentially leading to loss of funds or other severe consequences.

Here is how to fix it:

modifier onlyAdmin {
     // Assume this is correctly implemented
        _;
    }
function delegate (address addr) external onlyAdmin
{  addr.delegatecall(abi.encodeWithSignature("setDelay(uint256)"));
    }

In this updated contract, the delegate function now has the onlyAdmin modifier, meaning it can only be called by the admin of the contract. This helps ensure that only trusted parties can make delegate calls, mitigating the risk of a controlled delegatecall vulnerability

Potential controlled delegatecall risk

In the above code snippet, the delegate function allows any external caller to specify an address addr to make a delegate call to. This is a potential security risk as an attacker can specify an address of a malicious contract that manipulates the state of your contract in unintended ways.

To mitigate this risk, you can implement restrictions on what addresses can be called, or what functions can be invoked via delegatecall. For instance, you can have a list of approved addresses stored in your contract, and only allow delegatecalls to those addresses.

Here is how you can modify your contract to incorporate this change:

pragma solidity 0.8.4;

contract test {

    // Assume other required functionality is correctly implemented

    modifier onlyAdmin {
     // Assume this is correctly implemented
        _;
    }

    // List of approved addresses that can be called
    address[] approvedAddresses;

    function delegate (address addr) external onlyAdmin {  
        // Check if the address is approved
        require(isApproved(addr), "Address not approved");

        addr.delegatecall(abi.encodeWithSignature("setDelay(uint256)"));
    }

    // Function to check if an address is approved
    function isApproved(address addr) internal view returns (bool) {
        for (uint i = 0; i < approvedAddresses.length; i++) {
            if (approvedAddresses[i] == addr) {
                return true;
            }
        }
        return false;
    }

    // Function to add an approved address (only callable by admin)
    function addApprovedAddress(address addr) external onlyAdmin {
        approvedAddresses.push(addr);
    }
}

In the above code, we've added an array approvedAddresses to store the list of approved addresses. A new function isApproved is added to check if an address is in the approved list. The delegate function now checks if the address is approved before making the delegatecall. A new function addApprovedAddress is added to allow the admin to add new approved addresses

Please note that the onlyAdmin modifier is assumed to be implemented correctly, and it ensures that only the admin can call the delegate and addApprovedAddress functions. This is a basic measure of protection, and depending on your use case, you may need to implement more complex access control mechanisms.

delegatecall return value is not checked

In the original code snippet, the return value of the delegatecall function is not checked. This is a potential issue because if the delegatecall fails for any reason (for example, if the called contract does not exist or does not have the specified function), your contract will not be aware of the failure and will continue execution as if the delegatecall was successful. This can lead to unexpected behavior and potential security vulnerabilities, as it can result in your contract state being inconsistent or incorrect.

To fix this issue, you should always check the return value of the delegatecall function and handle any failures appropriately. Here is an example of how you can modify your contract to check the return value:

function delegate (address addr) external onlyAdmin {  
        // Check if the address is approved
        require(isApproved(addr), "Address not approved");

        (bool success, bytes memory data) = addr.delegatecall(abi.encodeWithSignature("setDelay(uint256)"));

        // Check if the delegatecall was successful
        require(success, "Delegatecall failed");
    }

In the above code, we've added a check for the success return value of the delegatecall function. If delegatecall fails, the require statement will cause the transaction to revert, preventing any state changes

delegate() does not check for contract existence at addr

In the original code snippet, the delegate function doesn't check if the contract exists at the provided address addr. This could lead to potential problems. If the delegatecall method is called with an address that doesn't contain a contract, it will fail silently, meaning it will consume all the gas provided but won't perform any action. This could lead to a loss of gas fees and the function not behaving as expected.

To mitigate this, you can add a function in your contract to check if another address contains a contract. One way to do this is by checking the extcodesize of the address, which returns the size of the code at that address. If the size is greater than zero, it indicates that there is a contract at that address. Here's how you can implement this:

// Create a function to check if an address is a contract
    function isContract(address addr) internal view returns (bool) {
        uint32 size;
        assembly {
            size := extcodesize(addr)
        }
        return (size > 0);
    }

        function delegate (address addr) external onlyAdmin {  
                // Check if there is a contract at the address
                require(isContract(addr), "No contract at the provided address");

            }

In this updated contract, a new function isContract is added which checks if there is a contract at the provided address. The delegate function now checks if there is a contract at the address before making the delegatecall

pragma solidity 0.8.4;

contract test {

    // Assume other required functionality is correctly implemented

    modifier onlyAdmin {
     // Assume this is correctly implemented
        _;
    }

    // List of approved addresses that can be called
    address[] approvedAddresses;

    function delegate (address addr) external onlyAdmin {  
        // Check if the address is approved
        require(isApproved(addr), "Address not approved");

        // Check if there is a contract at the address
        require(isContract(addr), "No contract at the provided address");

        (bool success, bytes memory data) = addr.delegatecall(abi.encodeWithSignature("setDelay(uint256)"));

        // Check if the delegatecall was successful
        require(success, "Delegatecall failed");
    }

    // Function to check if an address is approved
    function isApproved(address addr) internal view returns (bool) {
        for (uint i = 0; i < approvedAddresses.length; i++) {
            if (approvedAddresses[i] == addr) {
                return true;
            }
        }
        return false;
    }

    // Function to check if an address is a contract
    function isContract(address addr) internal view returns (bool) {
        uint32 size;
        assembly {
            size := extcodesize(addr)
        }
        return (size > 0);
    }

    // Function to add an approved address (only callable by admin)
    function addApprovedAddress(address addr) external onlyAdmin {
        approvedAddresses.push(addr);
    }
}

The extcodesize opcode returns the size of the code on an address. If the size is larger than zero, the address is a contract. It is crucial to emphasize that extcodesize should used with caution. EXTCODESIZE returns 0 if it is called from the constructor of a contract.

Best Practices for Using delegateCall()

  1. Always validate the address: Before using delegateCall(), verify the target contract address is valid and trustworthy. Check for existence and authenticity.

  2. Check the return value: Always validate the return value of delegateCall() to confirm successful function execution and handle errors gracefully to prevent unexpected behavior in the calling contract.

  3. Use appropriate access control: Use modifiers to limit delegateCall() execution to authorized users or contracts, preventing security breaches.

  4. Limit the scope of delegateCall(): It is important to avoid invoking critical or sensitive functions using delegateCall() to minimize the impact of potential vulnerabilities.

Conclusion

As developers, it's crucial to recognize that the security of our smart contracts isn't just a concern—it's an absolute necessity. The buggy code snippets we've explored serve as cautionary tales, reminding us of the real-world consequences of oversight. By diligently validating addresses, checking return values, implementing robust access controls, and limiting the scope of delegateCall(), we fortify our contracts against potential exploits.

However, our journey doesn't end here. The landscape of blockchain technology is ever-changing, with new challenges and innovations emerging regularly. Embracing a mindset of continuous learning and adaptability is key. Stay informed about the latest security best practices, engage with the vibrant developer community, and never hesitate to seek guidance when in doubt.