在 Solidity 智能合约开发中,DoS(Denial of Service,拒绝服务)攻击是一个常见且需要关注的安全问题。本文探讨了 Solidity 中的三种主要 DoS 攻击类型及其防御方法
Solidity 中的 DoS (Denial of Service:拒绝服务攻击)可以大致分为如下几种:
拒绝 Ether 攻击指的是,某些情况下,智能合约的运行逻辑中,其中一个部分是向某一地址转账,且没有检查该地址的类型(EOA or CA)。此时,若 receiver 是一个未实现 fallback/receive 的合约账户,那么便会导致整个函数执行失败,回滚。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract KingOfEther {
address public king;
uint public king;
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()
时,合约会尝试向 king
进行转账,但是若 king 是如下形象的合约账户:
contract KingOfHack {
// some code
receive() payable external {
revert();
}
}
当该合约接收到 ether 时触发 receive()
函数,直接回滚,所以这会导致上面的合约无法继续运行。
修改方法很简单,最简单的方法便是采用“Pull Payment”模式 。
Pull Payment 是一种设计模式,在这种模式下,合约不主动向用户转账(Push Payment),而是记录用户可以提取的余额,由用户主动调用合约中的提现函数来领取自己的资金。
这种模式的核心思想是:
使用该模式有很多优点:
receive
或 fallback
函数,或者合约逻辑故意回滚,主动转账(Push Payment)可能会失败,导致整个交易回滚。Pull Payment 让用户自己发起提现,避免合约承担转账的责任。Out Of Gas Attack 实际上由于大量的循环遍历,导致 gas 超过 gas limit 上限引起整个交易的回滚。
这是一个涉及到外部调用的攻击。我们对上一个例子进行修改:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract KingOfEther {
address public king;
uint public balance;
mapping(address => uint256) public collectedAmount;
function claimThrone() external payable {
require(msg.value > balance, "Need to pay more to become the king");
(bool sent, ) = king.call{value: balance}("");
if (!sent) {
collectedAmount[king] += balance;
}
balance = msg.value;
king = msg.sender;
}
function withdraw() external {
uint256 amount = collectedAmount[msg.sender];
require(amount > 0, "No funds to withdraw");
collectedAmount[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Failed to send Ether");
}
}
看起来我们解决了刚刚的问题,就算 king 是一个不能接收 ether 的合约,那我们就把金额存在 mapping 中,等待用户取领取(领取是否成功就和我们没什么关系了)。但是,又有了新的问题。
这里涉及到了外部调用,对于 king 合约的 receive 函数中的逻辑,是任意的,由于交易的原子性问题,当交易运行中,若 gas 超过了 gas limit,那么这笔交易就会回滚。我们可以这样来构造恶意的 KingOfHack 合约
contract KingOfHack {
// some code
receive() payable external {
while(true) {}
}
}
在新的攻击合约中,我们在 receive 函数中写了一个死循环,这导致当 KingOfEther 合约向我们转账时,会触发到这个死循环,这样就会导致整个交易 out of gas,交易回滚。
我们可以通过限制单笔交易的 gas 使用量来避免这个问题。我们对合约的claimThrone
函数进行如下的修改:
function claimThrone() external payable {
require(msg.value > balance, "Need to pay more to become the king");
(bool sent, ) = king.call{gas: 100000, value: balance}("");
if (!sent) {
collectedAmount[king] += balance;
}
balance = msg.value;
king = msg.sender;
}
这样,当外部调用消耗的 gas 达到 100000 时,会导致外部调用回滚。但是,由于整个交易的 gas 还有剩余,剩下的逻辑依然是可以成功执行的。
刚刚修改后的代码看起来似乎又没有什么问题了,但是实际上还有一种 Dos 攻击:returnbomb Attack
Return Bomb Attack 是一种更为隐蔽的 DoS(Denial of Service)攻击 类型,其原理是利用以太坊的 call
方法返回的数据量对合约进行攻击。在 Solidity 中,当一个合约通过 call
调用另一个合约时,如果返回的数据量非常大,会导致消耗大量 Gas,从而引发 Gas 限制或使交易失败。
虽然我们限制了外部调用的 gas,但是 returnbomb attack 消耗的 gas 并不是外部调用部分的 gas,而是外部调用执行完毕后,evm 会将运行后的 returndata copy 到 memory 中,若 return data 很大,会消耗大量的 gas 导致整个交易 out of gas,交易回滚
原理:
call
方法会将目标合约返回的所有数据存储到调用合约的内存中。攻击方式:
不过需要注意一点:
在防御 Out of Gas 攻击的代码中,由于单笔外部调用的 gas
限制被设置为 100,000
,当攻击者的合约试图通过 receive
函数返回大量数据来进行攻击时,gas
消耗会超出 100,000
的限制。此时,外部调用会因 Gas 不足(Out Of Gas) 而直接失败,返回的错误信息将是 Out Of Gas,而不是攻击合约中 receive
函数的逻辑触发的异常。所以,returnbomb Attack 攻击时,并不是返回的数据越多越好。最大数据量需要我们进行计算。
EVM 的 gas 消耗跟 memory 使用量的关系是:
memory_size_word = (memory_byte_size + 31) / 32
memory_cost = (memory_size_word ** 2) / 512 + (3 * memory_size_word)
contract KingOfHack {
receive() external payable {
assembly {
return(0, `value`)
}
}
}
针对这个例子的 ReturnBomb Attack 可能会失效, 因为其他逻辑对 gas 的消耗较少。不过改攻击在 Dos 攻击中确实存在,某些跨链协议就很有可能遭受 ReturnBomb Attack
我们可以在处理返回数据前,增加对返回数据长度的检查。 OpenZeppelin 合约安全库中有相关的安全库合约。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!