solidity DoS Attack
Solidity 中的 DoS (Denial of Service:拒绝服务攻击)可以大致分为如下几种:
- 拒绝 Ether 转账攻击
- Out of Gas Attack
- Returnbomb Attack
拒绝 Ether 攻击
拒绝 Ether 攻击指的是,某些情况下,智能合约的运行逻辑中,其中一个部分是向某一地址转账,且没有检查该地址的类型(EOA or CA)。此时,若 receiver 是一个未实现 fallback/receive 的合约账户,那么便会导致整个函数执行失败,回滚。
example:
// 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 模式
Pull Payment 是一种设计模式,在这种模式下,合约不主动向用户转账(Push Payment),而是记录用户可以提取的余额,由用户主动调用合约中的提现函数来领取自己的资金。
这种模式的核心思想是:
- 不要主动转账(Push)。
- 让用户自己提取资金(Pull)。
使用该模式有很多优点:
- 避免转账失败
如果用户地址是一个合约地址且没有实现receive
或fallback
函数,或者合约逻辑故意回滚,主动转账(Push Payment)可能会失败,导致整个交易回滚。Pull Payment 让用户自己发起提现,避免合约承担转账的责任。 - 防止重入攻击
在 Push Payment 模式下,合约直接调用外部地址,可能引发重入攻击。而在 Pull Payment 中,提现过程由用户主动发起,合约内部无需主动调用外部地址,大大降低了重入攻击的风险。 - 提升灵活性
Pull Payment 允许用户在需要时提取资金,而不需要立即接收,这在某些情况下(例如延迟支付)更加灵活。
Out Of Gas Attack
Out Of Gas Attack 实际上由于大量的循环遍历,导致 gas 超过 gas limit 上限引起整个交易的回滚。
这是一个涉及到外部调用的攻击。我们对上一个例子进行修改:
example
// 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 还有剩余,剩下的逻辑依然是可以成功执行的。
Returnbomb Attack
刚刚修改后的代码看起来似乎又没有什么问题了,但是实际上还有一种 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,交易回滚
原理:
- 在 Ethereum 中,
call
方法会将目标合约返回的所有数据存储到调用合约的内存中。 - 如果目标合约返回的数据非常大,会导致调用合约消耗大量 Gas 以分配和处理内存。
- 这种高 Gas 消耗可能导致交易失败或阻止其他用户的正常操作。
攻击方式:
- 攻击者部署一个恶意合约,返回非常大的数据块。
- 当受害合约调用该恶意合约时,由于需要处理大量返回数据,Gas 被迅速消耗殆尽。
不过需要注意一点:
在防御 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)
example
contract KingOfHack {
receive() external payable {
assembly {
return(0, `value`)
}
}
}
针对这个例子的 ReturnBomb Attack 可能会失效, 因为其他逻辑对 gas 的消耗较少。不过改攻击在 Dos 攻击中确实存在,某些跨链协议就很有可能遭受 ReturnBomb Attack
解决方法
我们可以在处理返回数据前,增加对返回数据长度的检查。 OpenZeppelin 合约安全库中有相关的安全库合约。