文章目录
外部调用函数
在 Solidity 中,常见的两种底层外部函数调用方式是:call 和 delegatecall。
说明:callcode 已在 Solidity 0.5.0 版本后弃用,不再推荐使用,因此本文不再介绍。
call
call 是一种底层调用方法,适用于向其他合约发送消息或调用函数,同时也支持附带以太币(Ether)。它语法灵活,常用于动态调用。
(bool success, bytes memory data) = target.call{value: msg.value}(abi.encodeWithSignature("func(uint256)", 123));
特点:
1.使用的是目标合约的代码和存储环境;
2.当前合约的 msg.sender 和 msg.value 不会传递给被调用合约;
3.返回值为 (bool success, bytes memory data),失败时不会自动回退(revert),需手动处理;
4.常用于与未知接口或外部插件合约交互(如代理合约);
5.可发送 Ether,适用于多种场景。
delegatecall
delegatecall 也是底层调用方式,但与 call 不同,其执行上下文是当前合约的环境。
(bool success, bytes memory data) = target.delegatecall(abi.encodeWithSignature("func(uint256)", 123));
特点:
1.调用目标合约的代码,但使用的是当前合约的存储(storage)、msg.sender 和 msg.value;
2.目标合约不应定义自己的状态变量,否则可能引发存储冲突;
3.常用于代理合约(Proxy Pattern)与可升级合约场景;
4.存在安全风险,需谨慎使用被调用合约,防止覆盖关键变量。
call 与 delegatecall 的区别示例
我们通过一个对比实验,直观理解两者在执行环境中的差异。
编写并部署如下的 A 合约:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract A {
address public a;
function test() public returns (address b) {
b = address(this); // 获取当前合约地址
a = b; // 保存到状态变量 a 中
}
}
合约部署完成后,记录合约 A 地址为 0xcD6a42782d230D7c13A74ddec5dD140e55499Df9。
我们将 A 合约地址填入 B 合约中,并部署如下代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract B {
address public a;
// A 合约地址
address Aaddress = address(0xcD6a42782d230D7c13A74ddec5dD140e55499Df9);
// 使用 call 调用 A.test(),并检查返回值
function testCall() public {
(bool success, ) = Aaddress.call(abi.encodeWithSignature("test()"));
require(success, "call failed");
}
// 使用 delegatecall 调用 A.test(),并检查返回值
function testDelegatecall() public {
(bool success, ) = Aaddress.delegatecall(abi.encodeWithSignature("test()"));
require(success, "delegatecall failed");
}
}
部署后初始状态
在未调用任何函数前,查看合约 A 和 B 中 a 变量的值。
A 合约的 a 为地址 0(即未被赋值):
B 合约的 a 同样为地址 0:
当我们分别调用 B.testCall() 和 B.testDelegatecall() 时,它们都会以不同的方式调用 A 合约中的 test() 函数。
让我们观察在这两种调用方式下,A 合约和 B 合约中的 a 变量会发生怎样的变化。
调用B.testCall()函数
我们首先调用 B.testCall() 函数:
调用完成后,我们先查看 B 合约中 address a 的值,发现它并未发生变化:
接下来我们查看 A 合约中的 address a,可以看到它已经被赋值,当前值为 0xcD6a42782d230D7c13A74ddec5dD140e55499Df9,与我们之前部署的 A 合约地址一致:
也就是说,当合约 B 使用 call 函数调用合约 A 的外部函数时,变量 a 的赋值结果来源于合约 A 在其自身上下文中执行操作后的返回值。
由此我们可以得出结论:当合约 B 通过 call 函数调用外部函数时,执行的是被调用合约 A 的代码逻辑,并在被调用合约的存储上下文中完成操作,因此不会影响当前调用合约自身的状态数据。
调用B.testDelegatecall()函数
先重新部署 A 和 B 合约(以清除旧数据)
在调用 B.testDelegatecall() 函数之前,先查看 A 合约中 address a 的值,尚未赋值:
可以看到,B 合约中的 address a 也尚未赋值,同时我们确认当前 B 合约的地址为:0x5FD6eB55D12E759a21C09eF703fe0CBa1DC9d88D:
接下来我们调用 B.testDelegatecall() 函数:
调用之后,我们查看 B 合约中 address a 的值,可以看到它已被赋值为 0x5FD6eB55D12E759a21C09eF703fe0CBa1DC9d88D,也就是当前 B 合约的地址:
随后我们再次查看 A 合约中的 address a,结果发现其值并未发生改变:
因此,当我们使用 B.testDelegatecall() 调用 A.test() 时,test 函数的代码逻辑是在 B 合约的环境中执行的,实际上是将 A 合约中的 test() 函数的代码带入 B 合约执行。因此,B 合约的地址被赋值给了变量 a,但这一操作并不会影响 A 合约中的数据。
区别总结
1.当合约 B 使用 call 调用合约 A 的外部函数时,调用发生在合约 A 的上下文中,返回的结果来自合约 A 执行后的值,且不会影响合约 B 的存储。调用时,合约 B 的状态不会受到影响。
2.当合约 B 使用 delegatecall 调用合约 A 的函数时,执行发生在合约 B 的上下文中,且所有的存储操作都会影响 B 合约的状态而非 A 合约。因此,B 合约的地址被赋值给了 a,而 A 合约的数据保持不变。
3.call 是在被调用合约的环境中执行的,而 delegatecall 则是在调用合约的环境中执行,且会修改调用合约的存储。
漏洞代码
存在一漏洞代码如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
// 定义一个名为 Lib 的合约
contract Lib {
address public owner;
// pwn 函数会将合约的 owner 设置为调用者的地址
function pwn() public {
owner = msg.sender; // 将合约的 owner 地址设置为调用者(msg.sender)
}
}
// 定义一个名为 HackMe 的合约
contract HackMe {
address public owner; // 合约的拥有者
Lib public lib; // 引用 Lib 合约的地址
// 构造函数,接收一个 Lib 合约的地址作为参数
constructor(Lib _lib) {
owner = msg.sender; // 设置 HackMe 合约的拥有者为部署者
lib = Lib(_lib); // 初始化 Lib 合约的地址
}
// fallback 函数,当合约接收到以太币时,执行 delegatecall 调用 Lib 合约的 pwn 函数
fallback() external payable {
address(lib).delegatecall(msg.data); // 使用 delegatecall 调用 Lib 合约的函数,并传递调用的数据
}
}
代码审计
上述代码中,Lib 合约包含一个 pwn() 函数,其作用是将合约中的 owner 设置为当前调用者的地址(msg.sender)。
HackMe 合约维护了一个 owner 变量和一个 Lib 合约实例 lib。在构造函数中,lib 的地址被初始化,同时将 owner 设置为部署者。
当 HackMe 合约收到以太币或被调用但函数签名未匹配时,fallback() 函数会触发,并通过 delegatecall 调用 Lib 合约中的方法,并将调用数据 msg.data 传递进去。
由于 delegatecall 会在当前合约(即 HackMe)的上下文中执行目标合约(即 Lib)的代码,攻击者可以通过调用 HackMe.pwn() 来执行 Lib.pwn(),从而将 HackMe 合约中的 owner 更改为自己。
攻击代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
// Attack 合约
contract Attack {
address public hackMe;
// 构造函数,通过传入 HackMe 合约地址初始化 hackMe
constructor(address _hackMe) {
hackMe = _hackMe; // 保存 HackMe 合约的地址
}
// 攻击函数,通过调用 HackMe 合约的 pwn() 函数来执行攻击(存在这个函数吗?接着往下看)
function attack() public {
hackMe.call(abi.encodeWithSignature("pwn()"));
}
}
攻击原理解析
我们先回顾一下 fallback 函数的触发时机:
向合约地址直接发送以太币时;
调用合约中不存在的函数时。
在本攻击场景中,attack() 函数调用 HackMe.pwn(),但 HackMe 中并未定义该函数,于是 fallback 被触发。fallback 中的 delegatecall(msg.data) 使用调用数据 “pwn()” 去调用 Lib.pwn(),而 Lib 中恰好存在该函数。
关键点在于:由于使用的是 delegatecall,所以 Lib.pwn() 实际是在 HackMe 的上下文中执行,修改的也是 HackMe 的存储空间。更具体地说,pwn() 函数修改的是第一个状态变量 owner,刚好与 HackMe 中的存储位置匹配,于是 HackMe 的 owner 被成功篡改。
简而言之,攻击者等于是将 Lib.pwn() 拿到 HackMe 合约中执行,实现了对 HackMe 控制权的劫持。
攻击流程
我们以角色 User(受害者)和 Attacker(攻击者)为例,梳理整个攻击过程如下:
1、User 部署 Lib 合约;
2、User 部署 HackMe 合约,并将 Lib 合约地址作为构造参数传入;
3、Attacker 部署 Attack 合约,并传入 HackMe 合约地址;
4、Attacker 调用 Attack.attack(),从而通过 delegatecall 执行 Lib.pwn();
5、HackMe 合约中的 owner 被成功更改为 Attacker。
6、Attacker 可提取合约中所有的 ETH 或 Token;升级合约逻辑为恶意代码等。
修复建议
通过这个例子我们可以清晰看到 delegatecall 带来的安全隐患 —— 如果调用的外部合约不可信或不受控,很可能造成严重的数据篡改或权限劫持问题。因此,在设计合约架构时,应注意以下两点。
1.在使用 delegatecall 时,应确保被调用合约的地址是可信且不可由外部用户控制的。推荐的做法是将目标地址硬编码(写死一个你自己部署、你知道安全的合约地址),或通过白名单机制进行管理,防止恶意地址注入。
如果目标地址是外部可控的,会发生什么?
举个例子:
fallback() external payable {
address(_someUserInput).delegatecall(msg.data);
}
假设上面这段代码的 _someUserInput 是用户传进来的地址,此时攻击者可以传一个他部署的恶意合约地址进来,然后通过 delegatecall,在你的合约中执行他的恶意代码,直接改写你合约里的变量,比如 owner、余额等。
2.在复杂合约中使用 delegatecall 时,应确保调用者与被调用合约之间的存储结构保持一致。特别是变量的声明顺序和类型必须严格对应,否则容易因存储插槽错位而导致意外的数据覆盖或逻辑错误。
为什么?这是因为 delegatecall 有一个关键特性:当涉及 storage 变量的修改时,变量的赋值是基于存储插槽的位置(slot)而不是变量名进行的。
举例来说:
1.在 A 合约中,address c 被存储在 slot0,而 address a 被存储在 slot1;
2.在 B 合约中,address a 被存储在 slot0,而 address c 被存储在 slot1;
3.当 B 合约通过 delegatecall 调用 A 合约的 test 函数时,该函数原本用于修改 A 合约中的变量 a,即 slot1 中的数据;
4.然而,在 delegatecall 的上下文中,slot1 实际映射的是 B 合约中的变量 c,因此最终被修改的是 B 合约中的 c,而不是预期的 a。
审计思路
1.在审计中,如发现合约使用了 delegatecall,需重点检查目标合约地址是否有被攻击者控制的可能。如果地址是可控的,即存在外部调用者传入的风险,应视为高危漏洞。
2.若被调用的合约中包含对 storage 变量的修改,审计者应对比其与调用方合约的变量声明顺序和存储插槽分布,确认两者结构是否一致,避免 delegatecall 引发变量误覆盖或权限篡改问题。
最后,推荐继续阅读【区块链安全 | 第四十篇】合约审计之delegatecall(二)