了解一个既存在于传统网络安全又存在于智能合约安全中的问题——拒绝服务。
By:小白
在上次的文章中我们学习了智能合约中获取随机数常用的几种方式,以及他们的优缺点,还介绍了伪随机数在智能合约中会造成哪些危害,这次我们来了解一个既存在于传统网络安全又存在于智能合约安全中的问题——拒绝服务。
传统网络安全拒绝服务攻击(DoS):DoS 是 Denial of service 的简称,即拒绝服务,任何对服务的干涉,使得其可用性降低或者失去可用性均称为拒绝服务。常见的针对网络协议造成拒绝服务的攻击手段大致有以下几种:SYN Flood,IP 欺骗性攻击,UDP 洪水攻击,Ping 洪流攻击,Teardrop 攻击,Land 攻击,Smurf 攻击,Fraggle 攻击等。
智能合约拒绝服务攻击:可以导致智能合约无法正常使用的代码逻辑错误,兼容性错误或调用深度过大(区块链虚拟机的特性)的安全问题。智能合约中的拒绝服务攻击手法就相对比较简单,包括但不限于以下三种:
1、基于代码逻辑的拒绝服务攻击:这种类型的拒绝服务攻击一般情况下是因为合约代码逻辑的不严谨造成的,最典型的就是当合约中存在对传入的映射或数组循环遍历的逻辑且没有限制传入的映射或数组的长度时攻击者可以通过传入超长的映射或者数组进行循环遍历而大量消耗 Gas 从而该笔交易的 Gas 溢出,最后使得智能合约暂时或永久不可操作。
2、基于外部调用的拒绝服务攻击:这种拒绝服务攻击是建立在合约中对外部调用处理不当导致的。例如智能合约中存在基于外部函数执行的结来改变合约状态且没有对交易一直失败的情况做出处理,攻击者会利用这个特点故意使交易失败,智能合约则会一直重复这笔失败的交易从而造成智能合约逻辑卡在这里不能继续执行,最后使得智能合约暂时或永久不可操作。
3、基于运营管理的拒绝服务攻击:这种拒绝服务攻击就是建立在后期运营情况下,例如在智能合约中通常会存在以 Owner 账户作为管理员角色,该角色通常会持有很高的权限,例如开启或暂停转账功能,当 Owner 角色操作失误或私钥丢失可能会受到非主观意义上的拒绝服务攻击。
通过前置知识相信大家已经对拒绝服务这类攻击有一定的了解了,在触发拒绝服务攻击的三种手法中最典型的就是基于外部调用的拒绝服务攻击。下面我们就通过一段典型的代码示例来带大家深入了解:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract KingOfEther {
address public king;
uint public balance;
function claimThrone() external payable {
require(msg.value > balance, "Need to pay more to become the king");
(bool sent, ) = king.call{value: balance}("");
require(sent, "Failed to send Ether");
balance = msg.value;
king = msg.sender;
}
}
我们可以看到上述合约的目的是选取“以太之王”,玩家可以通过 claimThrone() 合约中打入大于之前用户的任意数量的以太币来竞争“以太之王”的称号,当打入的以太币高于之前玩家时打入的以太币留在合约中并获得“以太之王”称号,之前玩家的以太币会原路退回。
我们可以看到,生成新王和退回旧王的逻辑是在同一函数内完成的,并且 claimThrone() 中还检查了退款的返回值 sent,下面我们来结合这个特点来完成攻击。
注:以下合约代码逻辑以及攻击场景仅作演示示例,请勿胡乱联想。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract Attack {
KingOfEther kingOfEther;
constructor(KingOfEther _kingOfEther) {
kingOfEther = KingOfEther(_kingOfEther);
}
function attack() public payable {
kingOfEther.claimThrone{value: msg.value}();
}
}
首先我们先来分析攻击流程:
Alice 部署 KingOfEther 合约。
Alice 调用 KingOfEther.claimThrone() 发送 1 个以太到 KingOfEther 合约中成为“以太之王”。
高富帅 Bob 调用 KingOfEther.claimThrone() 发送 2 个以太到 KingOfEther 合约中成为新王。
Alice 收到 1 个以太币的退款。
Eve 使用 KingOfEther 的地址部署攻击合约 Attack。
Eve 调用 Attack.attack() 向 KingOfEther 合约中发送 3 个以太。
Attack 合约成为新王。
高富帅 Bob 觉得不服,再次调用 KingOfEther.claimThrone() 向 KingOfEther 合约中发送了 20 个以太展现自己的“钞能力”。
Bob 发现自己的交易一直被 revert,无法成为新王。至此,Eve 的攻击使 KingOfEther 合约永久失效,Attack 合约成为了永远的“以太之王”。
高富帅 Bob 觉得不可思议,为啥自己这么有钱还不能称王呢?我们来看看到底是为什么。
当 Bob 调用 KingOfEther.claimThrone() 发送 20 个以太到 KingOfEther 合约时会触发 KingOfEther.claimThrone() 的退款逻辑,将之前 Eve 通过 Attack.attack() 向 KingOfEther 合约中发送的 3 个以太原路退回到 Attack 合约。我们再来看 Attack 合约,该合约中没有实现 payable 的 fallback() 所以不能接收以太币,这将导致 KingOfEther.claimThrone() 的退款逻辑一直失败,退款返回值 sent 将一直为 false 无法通过 require(sent, "Failed to send Ether") 检查一直被 revert。因为只要触发退款就会被 revert 导致 KingOfEther 合约中继 Attack 合约后无人能成为新王,Eve 成功完成了拒绝服务攻击。
作为开发者
在智能合约开发中应当注意处理连续失败的情况,例如将可能出现失败的外部调用逻辑异步处理。
在使用 call 进行外部调用以及使用循环和遍历时应当注意 Gas 消耗。
避免对单个角色过度授权的情况,处理合约权限时应做到合理的权限划分,对拥有权限的角色使用多签钱包管理,防止由于私钥泄漏导致权限丢失。
下面是针对上面漏洞合约的修复示例:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract KingOfEther {
address public king;
uint public KingValue;
mapping(address => uint) public balances;
function claimThrone() external payable {
balances[msg.sender] += msg.value;
require(balances[msg.sender] > balance, "Need to pay more to become the king");
KingValue = balances[msg.sender];
king = msg.sender;
}
function withdraw() public {
require(msg.sender != king, "Current king cannot withdraw");
uint amount = balances[msg.sender];
balances[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Failed to send Ether");
}
}
可以看到修复合约中添加了 balances 映射,它记录了每个人向合约中打入以太的总数量相较于之前合约的优势是玩家失去王位后可以追加以太重新获得王位。修复版本的关键点是将退款逻辑作异步处理,需要玩家手动调用 withdraw() 来自助退款,就算遇到恶意玩家拒收以太也只能影响到自己,不会再造成之前的拒绝服务了。
作为审计者
注意合约中是否存在逻辑上的错误导致影响了可用性。
注意是否存在由于虚拟机调用深度过大导致的 DoS(深度最大 1024)。
重点关注在代码逻辑中是否存在大量消耗 Gas 的逻辑。
关注与外部合约进行交互的时候没有考虑好兼容性的问题,如:未处理 TRC20-USDT 的返回值的兼容性,导致代币被锁定。
重点检查有没有判断外部合约调用的返回值是否符合预期的效果。
注:本文参考于《Solidity by Example》
参考链接: https://solidity-by-example.org/hacks/randomness
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!