Intro to Smart Contract Security Audits | Delegatecall (1)

SlowMist
8 min readMay 30, 2022

--

Background

In the last article, we learned how the data in contracts are stored and how to read different types of data in contracts. One function in a smart contract, delegatecall, is what we’ll be going over in this article.

Prior-Knowledge

Let’s first go over the two common external function calls within a contract: call and delegatecall. Here’s an example on how to distinguish between the two different types.

contract A { address public a;
function test() public returns (address b){ b = address(this); a = b;
}}

Following its initial deployment, an address from Contract A will be received, with which we’ll use 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 utilize the B.testCall() or B.testDelegatecall() function, these two functions will call A.test(). We can observe the change of address ‘a’ in Contracts A & B.

First, let’s look at the value of address ‘a’ in both contracts after its deployment:

As you can see, the value of address ‘a’ in Contract A and Contract B are both ‘0’ after its deployment. We’ll now call the B.testCall() function to see what changes.

After the call, we can see the value of address ‘a’ in Contract B did not change.

Looking at contact A, you can see the value of address ‘a’ is now:

0x9F2b8EAA0cb96bc709482eBdcB8f18dFB12D3133.

Final notes: When a contract uses the call function to call an external function, the corresponding code is executed in the code environment of the called contract and has no effect on the caller.

For redeployment, the previous contract data needs to be cleared, therefore you call the B.testDelegatecall() function. It’s important to note that the addresses of the two contracts will also change when the contracts are redeployed.

Now, let’s check the value of address ‘a’ in contract B. Here we find that address ‘a’ has been successfully assigned a value, which is:
0xB25f1f0B4653b4e104f7Fbd64Ff183e23CdBa582.

Looking at the value of address ‘a’ in Contract A, we see that there hasn’t been any changes. So when using B.testDelegatecall() to call A.test(), the code logic in the test function is executed only in the environment of Contract B. This is equivalent to taking the code of A.test() to contract B for execution. This method will not have an affect on the data in Contract A.

From this example, we can demonstrate the differences between call and delegatecall functions:

Terms to remember:

  • call: After the call, the value of the built-in variable msg will be modified by the caller, and the execution environment is the callee’s runtime environment.
  • delegatecall: After the call, the value of the built-in variable msg will not be changed for the caller, but the execution environment is the caller’s runtime environment;
  • callcode: After the call, the value of the built-in variable msg will be modified by the caller, but the execution environment is the caller’s runtime environment. It should be noted that callcode has been disabled in all versions after 0.5.0, so we’re only limited in this case.

Below is a diagram to help you better understand these concepts:

Now that we’ve gathered an understanding between the differences of ‘delegatecall’ and ‘call’, we can now further look into their features. Let’s start with the ‘delegatecall’ function:

We’ll be using another example to explain this concept. This example will incorporate the storage method of variables in solidity, which is further explained in detail in our previous article, which you can read here: “Access to Private Data in Smart Contract Security Audit Introduction”.

We’ll be using the same contracts we created earlier, but we’ll be making some modifications to them. For this instance, we’ll be adding another address: 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 testDelegatecall() public{ Aaddress.delegatecall(abi.encodeWithSignature("test()")); }}

Looking at the code, we reversed the declaration order of address ‘a’ and address ‘c’ in both contracts. We then deployed the contract and utilized the call B.testDelegatecall() function to see what would happen. Note: the deployment process isn’t displayed

Let’s see what happens to the values ​​of address ‘a’ and address ‘c’:

Can you spot the problem? It’s clear that address ‘a’ was modified through A.test(). Why has the result from the call function changed only for address ‘c’, but not for address ‘a’?

This brings us to an interesting aspect of the delegatecall function. Utilizing the external call function involved the modification of the storage variables, which modified the storage location, not the variable name. In Contract A, address ‘c’ is stored in slot0 and address ‘a’ is stored in slot1. Whereas in Contract B, we have the opposite, address ‘a’ is stored in slot0, and address ‘c’ is stored in slot1. We call the test function in Contract A by calling the delegatecall function in Contract B. The test function modifies slot1 in Contract A, so the result of the code running address ‘c’ in Contract B is being modified. This is because Slot1 in Contract B corresponds to the location where address ‘c’ is stored.

Final notes: The slot location is used instead of the variable name when utilizing the delegatecall function to perform an external call that involves modifying the storage variable.

Vulnerability example

Now that we know the difference between “call” and “delegatecall,” we can move on. Let’s use what we’ve learned above for an attack scenario.

// SPDX-License-Identifier: MITpragma 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); }}

Vulnerability Analysis

We can see that there are two contracts. There is only one pwn function in the Lib contract which modifies the owner of the contract. There is a fallback function in the HackMe contract. The content 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() and change the owner in the HackMe contract to ourselves.

Attack contract

// SPDX-License-Identifier: MITpragma solidity ^0.8.13;
contract Attack { address public hackMe;
constructor(address _hackMe) { hackMe = _hackMe; }
function attack() public { hackMe.call(abi.encodeWithSignature("pwn()")); }}

Let’s invite our old friends back from our previous examples to further analyze this incident.

The victim is Alice and the attacker is Eve.

  1. Alice deploys the Lib contract;
  2. Alice deploys the HackMe contract and passes the address of the Lib contract in the constructor.
  3. The attacker Eve deploys the Attack contract and passes the HackMe address in the constructor.
  4. The attacker Eve calls the attack function to successfully change the owner in the HackMe contract to himself.

First, let’s figure out when the fallback function was triggered.
1. When transferring money directly to a contract, the fallback function in that contract will be triggered.
2. When calling a function that cannot match the function name to a contract, the fallback function in that contract will be triggered.

Here’s what happened:
The attack function first calls HackMe.pwn() and finds that there is no pwn function in the HackMe contract. At that time, the HackMe.fallback() is triggered and HackMe.fallback() uses deldegatecall to call the function in the Lib contract. The function name msg.data is “pwn()”, and there’s a function named pwn in the Lib contract. The goal of this function is to change the owner in the contract to msg.sender. Earlier, we learned that the execution environment of the delegatecall function is within the caller’s environment. Modifications to the storage variable are made based on the slot position of the contract that is being called.

In short, when HackMe runs the delegatecall function to call Lib.pwn(), it is the same as giving Lib.pwn() directly to the HackMe contract to run. The pwn function modifies the variable owner, whose storage location is slot0 in the Lib contract. So after HackMe calls the pwn function through the delegatecall, the variable whose storage location is slot0 in the HackMe contract happens to be the owner variable. The owner of the HackMe contract has successfully been retrieved, and the attacker, Eve, has been modified to be the owner.

This attack process may be a little confusing for beginners, but it is imperative to understand the trigger conditions of the fallback function and the characteristics of the delegatecall function. If you feel that you’ve grasped an understanding of the various features of the delegatecall function and are ready to learn more, you can look forward to our next article: “Delegatecall (2) in the Introduction to Smart Contract Security Audit”.

Repair suggestion

As a Developer
1. When using delegatecall, it should be noted that the address of the called contract cannot be controlled.
2. In more complex environments, you need to pay attention to the declaration order and storage location of the variables. The use of delegatecall for external calls will modify the data stored in the corresponding slot of the contract, based on the data structure of the called contract. This may cause unexpected variable coverage when the data structure changes.

As an Auditor
1. During the audit process, when the delegatecall is used in a contract, it’s necessary to pay attention to whether the called contract address is controllable.
2. When the function in the called contract modifies the storage variable, it’s necessary to pay attention to the location of the variable storage slot. This is to avoid the storage variable stored in the contract being incorrectly overwritten due to inconsistent data structures.

--

--

SlowMist
SlowMist

Written by SlowMist

SlowMist is a Blockchain security firm established in 2018, providing services such as security audits, security consultants, red teaming, and more.

Responses (1)