Solidity's delegateCall() Unveiled: Risks, Solutions, and Best Coding Practices
Photo by Shahadat Rahman on Unsplash
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()
Always validate the address: Before using delegateCall(), verify the target contract address is valid and trustworthy. Check for existence and authenticity.
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.
Use appropriate access control: Use modifiers to limit delegateCall() execution to authorized users or contracts, preventing security breaches.
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.