拒绝服务(DOS)
对智能合约进行DOS攻击的方法有很多种,其根本的目的是使合约在一段时间内或者永久无法正常运行,通过拒绝服务攻击,也可以使合约中的ether永远无法提取出来,下面将会列出几种常见的攻击场景:
未设定Gas费用的外部调用
在这种情况下,您可能希望对未知合同进行外部调用并继续处理事务,而不管该调用是否失败,通常通过call操作码来实现的,如果调用失败,调用操作码不会还原事务(有关更多详细信息和示例)
案例分析
下面我们考虑一个简单的例子,我们有一个钱包合约,当调用withdraw()函数时,它会逐渐的从钱包中取出ether,合作伙伴也可以添加他们的地址,并花费gas费用来调用withdraw()函数,然后给予合作伙伴和业主总合同余额的1%。
contract TrickleWallet {
address public partner; // withdrawal partner - pay the gas, split the withdraw
address public constant owner = 0xA9E;
uint timeLastWithdrawn;
mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances
function setWithdrawPartner(address _partner) public {
require(partner == '0x0' || msg.sender == partner);
partner = _partner;
}
// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint amountToSend = address(this).balance/100;
// perform a call without checking return
// the recipient can revert, the owner will still get their share
partner.call.value(amountToSend)();
owner.transfer(amountToSend);
// keep track of last withdrawal time
timeLastWithdrawn = now;
withdrawPartnerBalances[partner] += amountToSend;
}
// allow deposit of funds
function() payable {}
// convenience function
function contractBalance() view returns (uint) {
return address(this).balance;
}
}
注意,在第[18]行,我们执行一个外部调用,将合同余额的1%发送到用户指定的帐户,使用call操作码的原因是,即使外部call回滚,也要确保合约的owner仍然获得1%的报酬,而问题是,事务将把它的所有gas(实际上,只有大部分事务gas被发送,一些gas被留下来完成对调用的处理)发送到外部调用,如果用户是恶意的,他们可以创建一个消耗所有gas的合约,由于gas耗尽而强制所有事务withdraw()失败。
例如,考虑以下消耗所有gas的恶意合同:
contract ConsumeAllGas {
function () payable {
// an assert consumes all transaction gas, unlike a
//revert which returns the remaining gas
assert(1==2);
}
}
如果合约partner们不喜欢当前合约的owner,他们可以将合作伙伴地址设置为攻击合同地址,并将所有资金永远锁定在TrickleWallet中
防御措施
为了防止这种DOS攻击,请确保在外部调用中指定gas stipend,以限制该事务可以使用的gas量,在我们的例子中,我们可将L[18]更改为如下代码:
partner.call.gas(50000).value(amountToSend)();
这个修改只允许在外部事务上花费50000 gas,无论外部交易使用多少gas,合约的owner都可以设定高于此gas的价格,以完成其交易
外部操作的映射或数组循环
案例分析
通常情况下,它出现在合约的owner希望在其投资者之间分发token的场景中,并使用类似distribute()的函数来执行此操作,如示例合同中所示:
contract DistributeTokens {
address public owner; // gets set somewhere
address[] investors; // array of investors
uint[] investorTokens; // the amount of tokens each investor gets
// ... extra functionality, including transfertoken()
function invest() public payable {
investors.push(msg.sender);
investorTokens.push(msg.value * 5); // 5 times the wei sent
}
function distribute() public {
require(msg.sender == owner); // only owner
for(uint i = 0; i < investors.length; i++) {
// here transferToken(to,amount) transfers "amount" of tokens to the address "to"
transferToken(investors[i],investorTokens[i]);
}
}
}
注意,这个合约中的循环运行在一个数组上,这个数组可以被人为的扩展,攻击者可以创建多个用户帐户,使投资者阵列变大,原则上,这样做可以使执行for循环所需的gas超过块gas限制,实质上使distribute()函数不可操作。
防御措施
合约不应该循环对可以被外部用户人为操纵的数据结构进行批量操作,建议使用取回模式而不是发送模式,每个投资者可以通过使用withdrawFunds取回自己应得的代币,如果实在必须通过遍历一个变长数组来进行转账,最好估计完成它们大概需要多少个区块以及多少笔交易,然后你还必须能够追踪得到当前进行到哪以便当操作失败时从那里开始恢复,举个例子:
struct Payee {
address addr;
uint256 value;
}
Payee payees[];
uint256 nextPayeeIndex;
function payOut() {
uint256 i = nextPayeeIndex;
while (i < payees.length && msg.gas > 200000) {
payees[i].addr.send(payees[i].value);
i++;
}
nextPayeeIndex = i;
}
如上所示,必须确保在下一次执行payOut()之前另一些正在执行的交易不会发生任何错误,如果必须批量转账,请使用上面这种方式来处理~
owner操作
目前很多代币合约都有一个ower账户,其拥有开启/暂停交易的权限,如果对owner保管不善,代币合约可能被一直冻结交易,导致非主观的拒绝服务攻击~
案例分析
bool public isFinalized = false;
address public owner; // gets set somewhere
function finalize() public {
require(msg.sender == owner);
isFinalized == true;
}
// ... extra ICO functionality
// overloaded transfer function
function transfer(address _to, uint _value) returns (bool) {
require(isFinalized);
super.transfer(_to,_value)
}
在ICO结束后,如果特权用户丢失其私钥或变为非活动状态,owner无法调用finalize(),用户则一直不可以发送代币,即令牌生态系统的整个操作取决于一个地址。
防御措施
可以设置多个拥有owner权限的地址,或者设置暂停交易的期限,超过期限就可以恢复交易,如:require(msg.sender == owner || now > unlockTime)
基于外部调用的进程状态
如果智能合约的状态改变依赖于外部函数执行的结果,又未对执行一直失败的情况做出防护,那么该智能合约就可能遭受DOS攻击。
示例代码
pragma solidity ^0.4.22;
contract Auction {
address public currentLeader;
uint256 public highestBid;
function bid() public payable {
require(msg.value > highestBid);
require(currentLeader.send(highestBid));
currentLeader = msg.sender;
highestBid = currentLeader;
}
}
案列合约是一个简单的竞拍合约,如果当前交易的携带的ether大于目前highestBid,那么highestBid所对应的ether就退回给currentLeader,然后设置当前竞拍者为currentLeader,currentLeader改为msg.value。但是当恶意攻击者部署如下合约,通过合约来竞拍将会出现问题:
pragma solidity ^0.4.22;
//设置原合约接口,方便调用函数
interface Auction{
function bid() external payable;
}
contract POC {
address owner;
Auction auInstance;
constructor() public {
owner = msg.sender;
}
modifier onlyOwner() {
require(owner==msg.sender);
_;
}
//指向原合约地址
function setInstance(address addr) public onlyOwner {
auInstance = Auction(addr);
}
function attack() public onlyOwner {
auInstance.bid.value(msg.value)();
}
function() external payable{
revert();
}
}
攻击者先通过攻击合约向案例合约转账成为currentLeader,然后新的bider竞标的时候,执行到require(currentLeader.send(highestBid))会因为攻击合约的fallback()函数无法接收ether而一直为false,最后攻击合约以较低的ether赢得竞标。
防御措施
如果需要对外部函数调用的结果进行处理才能进入新的状态,请考虑外部调用可能一直失败的情况,也可以添加基于时间的操作,防止外部函数调用一直无法满足require判断。
逻辑设计缺陷
合约中的部分功能函数的逻辑设计导致DOS攻击。
案例分析
这里给出一个真实的案例——Edgeware锁仓合约的拒绝服务漏洞,具体代码如下:
https://github.com/hicommonwealth/edgeware-lockdrop/blob/master/contracts/Lockdrop.sol
在上面的代码中,有一段关键的代码"assert(address(lockAddr).balance == msg.value);",这段代码做了强制判断:属于参与者的Lock合约的金额必须等于参与者锁仓时发送的金额,如果不等于,意味着lock失败,这个失败会导致参与者的Lock合约"瘫痪"而形成"拒绝服务",直接后果就是:假如攻击持续着,Edgeware这个Lockdrop机制将不再可用,但这个漏洞对参与者的资金无影响,那么,什么情况下会导致"address(lockAddr).balance不等于 msg.value"?攻击者如果能提前推测出参与者的Lock合约地址就行,此时攻击者只需提前往参与者的Lock合约地址随便转点ETH就好.
防御措施
改一下逻辑即可:
Require条件
案例分析
如上图所示,这里的withdrawalSecurity用于提取保证金,在对应的逻辑中user为函数调用者,经过一系列的结构化查找(从user地址到index,在从mapping中的index索引到mapping等等一系列操作),之后来到提现判断条件:require(msg.value == amount, "amount not equals required value"); ,该条件看似没有什么问题,但是此时如果任意用户转入一笔很小量的资金到调用者账户来打破msg.value == amount的平衡,那么将导致直接回滚之前的操作,此时的用户无法提取保证金,导致DOS问题产生,但是该漏洞有一个弊端就是攻击者需要花费一部分资产来打破这种平衡,属于"自损利用型DOS攻击"。
防御措施
正确合理设计require条件检查语句~