【区块链安全 | 第三十六篇】合约审计之自毁函数

在这里插入图片描述

Solidity 转账方式

在 Solidity 中,合约之间的以太币转账通常有以下三种方式:

1.transfer:如果转账失败,会直接抛出异常,后续代码不会继续执行。

2.send:返回布尔值(true/false)表示是否转账成功,转账失败不会抛出异常,后续代码继续执行。

3.call.value().gas()()(推荐写法为 call{value: x, gas: y}()):同样返回布尔值,表示转账是否成功,转账失败不会抛出异常,后续代码继续执行。

【区块链安全 | 第三十四篇】合约审计之重入漏洞的最后一段,我们有讲到:使用 call 函数进行转账容易发生重入攻击,因为 call 是低级函数,call 在执行后会执行目标地址(msg.sender)的 fallback 或 receive 函数,即将控制权交给目标合约。

上述三种方式都有一个共通点:目标账户必须“接收”转账,交易才算成功。

selfdestruct——自毁函数

selfdestruct,也称自毁函数,是 Solidity 提供的原生命令,作用是销毁当前合约,并将该合约账户中所有剩余的以太币转入指定地址:

selfdestruct(payable(address));

它的特点是:无需目标账户接收就能强制转账。

当执行 selfdestruct 后,该合约的代码和存储数据会被从链上移除,账户余额也会被立刻发送给指定地址,即使指定地址是一个合约地址,并且没有 fallback 或 receive 函数也照样会成功。

为什么需要自毁函数

1.清理链上数据、释放资源
在区块链系统中,数据一旦写入区块链就会被永久保存,并且每个节点都需要存储这些数据。智能合约在运行过程中可能会产生大量的临时数据或不再需要的历史数据。

例如,一个记录用户交易历史的智能合约,随着时间的推移,早期的一些交易记录可能对于当前的业务逻辑来说已经不再重要,但它们仍然占据着链上的存储空间。

自毁函数可以将这些不再需要的数据进行清理,释放出宝贵的链上存储资源,降低整个区块链网络的存储成本。

2.在紧急情况下销毁合约、转移资产等场景
企业或项目方可能会因为业务策略的调整、项目失败或其他原因,需要终止智能合约的运行。自毁函数可以帮助他们在这种情况下有序地结束合约,并将合约中的资产转移到合适的地方,如转移到新的合约或项目中,或者返还给相关的用户或投资者。

例如,一个基于区块链的众筹项目,由于未能达到预期目标,项目方决定终止项目,通过自毁函数可以将众筹资金退还给投资者,并销毁相关的智能合约。

恶意利用自毁函数

因此我们可以试想这样一种情况:假如存在一个合约 A,攻击者能够让这个合约执行自毁操作,并将 A 合约中所有的余额转入攻击者控制的地址。

接下来,看一个存在该漏洞的智能合约示例代码。

漏洞代码

EtherGame 是一个简单的游戏合约,参与者每次只能存入 1 ETH,先达到目标金额的人获胜:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;


contract EtherGame {
    // 游戏目标金额为 7 ETH
    uint public targetAmount = 7 ether;

    // 存储赢家地址
    address public winner;

    // 玩家每次只能存入 1 ETH,直到总金额达到 7 ETH
    function deposit() public payable {
        // 限制每次只能发送 1 ETH
        require(msg.value == 1 ether, "You can only send 1 Ether");

        // 获取当前合约余额
        uint balance = address(this).balance;

        // 如果余额超过目标金额,游戏结束,不再接受存款
        require(balance <= targetAmount, "Game is over");

        // 如果刚好达到目标金额,记录当前发送者为赢家
        if (balance == targetAmount) {
            winner = msg.sender;
        }
    }

    // 赢家提取奖励(合约中全部 ETH)
    function claimReward() public {
        // 只有赢家可以调用
        require(msg.sender == winner, "Not winner");

        // 使用 call 将合约余额全部发送给赢家
        (bool sent, ) = msg.sender.call{value: address(this).balance}("");

        // 判断转账是否成功
        require(sent, "Failed to send Ether");
    }
}

代码审计

整体的流程如下:

1.玩家调用 EtherGame.deposit() 向合约中发送 1 个以太;

2.函数内部会校验 msg.value == 1 ether,确保玩家每次只能存入固定金额;

3.然后再检查当前合约余额是否小于或等于 7 ETH(即 address(this).balance <= targetAmount);

4.如果恰好达到目标金额,则当前玩家被设为 winner。

然而,问题正出在这一步:合约的余额是通过 address(this).balance 获取的,这就为攻击者留下了利用空间。address(this).balance 是 Solidity 中获取当前合约余额的标准方式。它表示:当前合约地址上所持有的 ETH 总数,不管这笔钱是怎么来的。所以这笔钱“不一定是通过函数进来的”。

因此,我们可以部署一个攻击合约,通过执行自毁函数(selfdestruct)将多枚 ETH 强制发送至 EtherGame 合约。这种转账方式绕过了 deposit() 函数的调用逻辑,因此不会触发设置 winner 的条件。

一旦 EtherGame 合约的余额因这笔强制转账而等于或超过 7 ETH,后续玩家转账时将无法通过 require(balance <= targetAmount) 的检查,从而无法继续参与游戏。

最终,合约中未产生合法的 winner,奖励也无法被领取,导致整个合约陷入“卡死”状态,形成一种典型的拒绝服务攻击(DoS)。

攻击代码

通过上面的思路,攻击合约如下所示:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

// 攻击合约
contract Attack {
    EtherGame etherGame;
    constructor(EtherGame _etherGame) {
        etherGame = EtherGame(_etherGame);
    }

    // 攻击函数
    function attack() public payable {
        // selfdestruct 函数会销毁合约并将合约余额发送给指定地址,
        // 因此接收的地址必须是 payable 类型,
        // 否则会导致编译错误。
        address payable addr = payable(address(etherGame));
        
        // 执行自毁操作,将当前攻击合约的余额(ETH)发送给目标 EtherGame 合约
        selfdestruct(addr);
    }
}

你可能会问:“那我连续七次调用目标合约,获胜的几率会不会很大呢?”

其实,想象一下:当你调用了六次目标合约并成功发送了6个ETH后,此时攻击者通过以太坊区块链浏览器看到目标合约的余额为6,并立即通过 selfdestruct 强制发送了2个ETH到合约中,那么合约的余额会迅速超过7个ETH。这样,后续玩家和你就无法再通过 deposit() 进入游戏,因为 require(balance <= targetAmount) 检查会失败。

修复建议

综上所述,这个漏洞不直接盗币,但可以让智能合约永久瘫痪、资金锁死、用户资产无法提取,造成严重的经济与信任损失,是典型的高危安全漏洞。

因此,可以修改合约代码,确保只有玩家通过 EtherGame.deposit 函数成功向合约存入以太后,合约的余额才会增加。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

contract EtherGame {
    // 游戏目标金额为 7 个 ETH
    uint public targetAmount = 7 ether;

    // 记录通过 deposit() 存入的 ETH 总额,
    // 避免使用 address(this).balance 以防止 selfdestruct 攻击
    uint public balance;

    // 获胜者地址
    address public winner;

    // 玩家通过该函数参与游戏,每次只能存入 1 个 ETH
    function deposit() public payable {
        // 校验玩家是否发送了恰好 1 个 ETH
        require(msg.value == 1 ether, "You can only send 1 Ether");

        // 记录玩家存入的 ETH,
        // balance 仅通过此处增加,防止外部强制转账影响逻辑
        balance += msg.value;

        // 检查当前累积的余额是否超过目标金额,超过则游戏结束并回滚
        require(balance <= targetAmount, "Game is over");

        // 如果达到目标金额,当前玩家成为赢家
        if (balance == targetAmount) {
            winner = msg.sender;
        }
    }

    // 胜利者调用此函数领取全部奖金
    function claimReward() public {
        // 只有赢家才能提取奖励
        require(msg.sender == winner, "Not winner");

        // 将奖金发送给赢家,
        // 这里用的是 balance,
        // 而不是 address(this).balance
        (bool sent, ) = msg.sender.call{value: balance}("");
        require(sent, "Failed to send Ether");
    }
}

合约的余额不再依赖于 address(this).balance,而是通过 deposit() 函数中的 balance 变量来维护。这样只有通过合法的存款(deposit()函数)操作才能修改余额,攻击者无法通过 selfdestruct 强制转账来影响合约状态。

审计思路

审计时,需结合合约的真实业务逻辑,仔细检查 address(this).balance 的使用是否会影响合约的正常功能。如果合约的行为依赖于 address(this).balance,且该余额可能受到攻击者强制注入非预期资金(例如通过 selfdestruct 攻击),则可以初步认为合约存在潜在的风险,可能会受到攻击并影响正常业务流程。

猜你喜欢

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