DASP_Top_10分析

DASP_Top_10分析

八月 15, 2018

以太坊智能合约DASP Top 10 分析

几个重要概念

回调函数—-fallback()

目前相当一部分Solidity的安全漏洞源于回退函数。

关于回退函数,官方文档解释如下:

1
2
3
4
5
6
A contract can have exactly one unnamed function. 
This function cannot have arguments and cannot return anything.
It is executed on a call to the contract if none of the other functions match the given function identifier (or if no data was supplied at all).

//fallback()是在合约中没有直接定义的,并且没有参数没有返回值的函数。
//当没有其他函数与给定的函数标识符匹配,或者没有数据传入时,fallback()会被调用。

简单来说:

  1. 当外部账户或其他合约调用了一个不存在的函数时
  2. 未向该合约发送任何数据时

fallback()函数会被调用。

三种交易的方式

Solidity 中,transfer(),send(),call.value()都可以用来向地址转入ether

transfer()
  1. 失败会throw,标记错误并恢复当前调用 (throw几乎等同于revert)
  2. 传递2300Gas,防止重入
send()
  1. 失败会返回false布尔值
  2. 传递2300Gas,防止重入
call.value()
  1. 失败会传递false布尔值
  2. 传递所有可用Gas,不能有效防止重入

Gas

EVM在执行代码时,每一步都会消耗一定的Gas,来防止智能合约出现死循环。

外部调用在调用合约中某函数时,会提供一定数量的Gas,如果提供的Gas大于调用该函数所需的Gas,则该函数会被成功执行;否则会抛出out of Gas的异常,然后合约状态回滚。

selfdestruct

任何合约都能实现selfdestruct(address): 将当前合约地址中的所有的字节码删除,并将储存在该合约的所有以太币发送到参数指定的地址。

所以,该函数可以用于强制将以太币发送到任意合约。

漏洞分析

这里将主要分析DASP Top 10

  1. Reentrancy
  2. Access Control
  3. Arithmetic Issues
  4. Unchecked Return Values For Low Level Calls
  5. Denial of Service
  6. Bad Randomness
  7. Front Running
  8. Time manipulation
  9. Short Address Attack
  10. Unknown Unknowns

并且一些其他的漏洞

  1. Unexpected Ether
  2. Tx.Origin Authentication

Reentrancy

1
2
3
this exploit was missed in review so many times by so many different people: reviewers tend to review functions one at a time, and assume that calls to secure subroutines will operate securely and as intended.

-- Phil Daian

重入漏洞,可以理解为由于递归引起的漏洞。

我们之前有提到过Gas的概念:EVM在执行代码时,每一步都会消耗一定的Gas,来防止智能合约出现死循环。

还有call.value()的交易方式,因为这个函数会传递所有可用Gas,不能有效防止重入。

来看一段示例代码:

1
2
3
4
5
6
7
8
function withdraw(uint _amount) {
require(balances[msg.sender] >= _amount);
//判断账户资产是否足够
msg.sender.call.value(_amount)();
//发送Ether
balances[msg.sender] -= _amount;
//修改账户资产数据
}

假设有一个公共钱包的场景。如果withdraw()函数由外部合约调用,并且外部合约包含了恶意构造的递归调用,则将会将公共钱包内的余额全部取出。

1
2
3
function () payable{
address.call(bytes4(keccak256("withdraw(address,uint256)")), this);
}

外部调用如果包含类似上面代码,就会出现重入的情况。

外部协议调用withdraw() –> withdraw()向外部合约发送Ether –> 外部合约收到Ether,触发fallback() –> fallback()调用withdraw ······

Access Control

1
2
3
it was possible to turn the Parity Wallet library contract into a regular multi-sig wallet and become an owner of it by calling the initWallet function.

-- Parity

访问控制。在Solidity中,有四种关键字用来控制函数或者变量的访问域:private,public,external,internal.

private: 该变量或函数只能在本合约被使用。但是以太坊是公链,所以这只是在代码层面的限制。

public: 该变量或函数可以被任意账户调用。

external: 该函数只能从外部调用。或使用this.func()的方式调用。

internal: 在合约继承中,父合约中被标记的变量或函数可以被子合约直接调用。

关于函数底层调用的三个函数:call(),delegatecall(),callcode()

call(): 用于外部调用,返回一个布尔值来表明外部调用成功与否。它可以被修饰

1
2
3
address.call.gas(1000000)("register", "MyName");
address.call.value(1 ether)("register", "MyName");
address.call.gas(1000000).value(1 ether)("register", "MyName");

delegatecall(): 将外部代码直接作用于当前合约的上下文,返回一个布尔值来表明外部调用成功与否。相当于在原合约处插入被调用函数的代码。与call的区别在于,它仅仅是调用了代码,它所调用的数据都是当前合约的数据。

callcode(): 与delegatecall()类似,但是它未提供对msg.sender,msg.value的访问权限。

如下示例代码:(OpenZeppelin CTF)

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
pragma solidity ^0.4.10;

contract Delegate {
address public owner;

function Delegate(address _owner) {
owner = _owner;
}
function pwn() {
owner = msg.sender;
}
}

contract Delegation {
address public owner;
Delegate delegate;

function Delegation(address _delegateAddress) {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}
function () {
if (delegate.delegatecall(msg.data)) {
this;
}
}
}

合约在fallback()中调用了delegate.delegatecall(),并且msg.data可控。

我们可以令msg.data = bytes4(keccak256("pwn()")),便可将delegation合约的owner修改为msg.sender

Arithmetic Issues

1
2
3
An overflow condition gives incorrect results and, particularly if the possibility has not been anticipated, can compromise a program’s reliability and security.

-- Jules Dourlens

算术问题,主要是整数溢出,包括整数上溢和整数下溢。

我们先以八位无符号整数为例,即uint8

八位无符号整形的范围是 [0,255]255的按位表示为11111111

如果再+1,则会变成00000000,最高位被舍弃。所以,(uint8)255 + 1 = 0

同理,(uint)0 - 1 = 255

八位有符号整数为例,即int8

八位有符号整形的范围是 [-128,127]127的按位表示为01111111。(最高位为符号位)

如果再+1,则会变成10000000,即128。所以,(int8)127 + 1 = -128

同理,(int8)(-128) - 1 = 127

示例代码:

1
2
3
4
5
function withdraw(uint _amount) {
require(balances[msg.sender] - _amount > 0);
msg.sender.transfer(_amount);
balances[msg.sender] -= _amount;
}

withdraw函数虽然在require(balances[msg.sender] - _amount > 0)对数据进行了检查,但是这里可以发生整数下溢。如果我们请求的数据大于余额,则会因为整数下溢导致结果依然大于0,使检查通过,从而获取到多余自己余额的货币,甚至在balances[msg.sender] -= _amount之后,余额数量变的特别大。

对于上述问题的修补方案,我们可以使用require(balances[msg.sender] > _amount)

Unchecked Return Values For Low Level Calls

1
2
3
The use of low level "call" should be avoided whenever possible. It can lead to unexpected behavior if return values are not handled properly.

-- Remix

没有严格的判断不安全函数的返回值。

这里请回顾一下前面提到的三种交易方式。

代码示例:

1
2
3
4
5
6
function withdraw(uint256 _amount) public {
require(balances[msg.sender] >= _amount);
balances[msg.sender] -= _amount;
etherLeft -= _amount;
msg.sender.send(_amount);
}

如果withdraw被调用,将Ether发送给不接受直接交易的合约,其返回值将会为false。但是由于没有检查send()返回值,将导致账户余额被扣除,收款方未收到交易。

Denial of Service

1
2
3
I accidentally killed it.

-- devops199 on the Parity multi-sig wallet

拒绝服务漏洞:通过对Gas的大量消耗,使原合约的逻辑无法正常运行,或者通过恶意操作使合约流程不可恢复。

先写一个消耗Gas的例子:

1
2
3
4
5
6
function selectNextWinners(uint256 _largestWinner) {
for(uint256 i = 0; i < largestWinner, i++) {
// heavy code
}
largestWinner = _largestWinner;
}

如果我们传入一个非常大的数字,由于Gas的限制规则,会阻塞函数的功能,导致largestWinner不会发生变化。

一个通过恶意操作使合约流程不可恢复的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pragma solidity ^0.4.10;

contract PresidentOfCountry {
address public president;
uint256 price;

function PresidentOfCountry(uint256 _price) {
require(_price > 0);
price = _price;
president = msg.sender;
}

function becomePresident() payable {
require(msg.value >= price); // must pay the price to become president
president.transfer(price); // we pay the previous president
president = msg.sender; // we crown the new president
price = price * 2; // we double the price to become president
}
}

这是一个类似KingOfEther的例子。出价高于当前合约的price就能成为新的president,并将合约里原有的存款返还给上一个president

如果调用becomePresident()的是外部合约,并且成功获取了president,并且外部合约的fallback()函数主导执行了revert(),那其他用户就无法正常执行becomePresident(),所以当前用户永久的成为了president

Bad Randomness

1
2
3
The contract had insufficient validation of the block.number age, which resulted in 400 ETH being lost to an unknown player who waited for 256 blocks before revealing the predictable winning number.

-- Arseny Reutov

伪随机一直都存在很多安全问题,并且在智能合约中,链上数据公开,就导致了随机数变成伪随机。

1
2
3
4
5
6
7
8
9
10
uint256 private seed;

function play() public payable {
require(msg.value >= 1 ether);
iteration++;
uint randomNumber = uint(keccak256(seed + iteration));
if (randomNumber % 2 == 0) {
msg.sender.transfer(this.balance);
}
}

虽然seed被标记为private,但在链上的数据都是相对公开的,同样iteration也可以被获取,所以randomNumber就变的可预测。

链上的数据都是公开的,所以想要得到一个真正的随机数还是比较难做到。

Front Running

1
2
3
Turns out, all it takes is about 150 lines of Python to get a working front-running algorithm.

-- Ivan Bogatyy

提前交易:在用户购买商品之前购买商品。

在区块链的交易中,所有的交易都需要经过确认才会被写在链上,并且每一笔交易都需要支付一定的手续费。手续费的多少影响了交易被处理的优先级。

假设工厂需要一车材料,对外招标,工厂一共有5个工人。用户A运输了一车材料到工厂,承诺给每个工人100元。(用户发起交易)随后Hacker得知了消息,也运输了一车材料到工厂,并且在工人搬运用户A的材料之前承诺给每个工人200元。(Hacker更高价格发起同一笔交易)所以工人都去搬运Hacker的材料,(Hacker的优先级高于用户,被优先确认)Hacker与工厂达成了交易,导致用户A的交易落空。

在以太坊中,所有未被确认的交易都是可被查看的,所以只需要更高价格发起交易就可以获取截获其他人的交易。

可以在https://etherscan.io/txsPending查询未被确认的交易。

Time manipulation

1
2
3
If a miner holds a stake on a contract, he could gain an advantage by choosing a suitable timestamp for a block he is mining.

-- Nicola Atzei, Massimo Bartoletti and Tiziana Cimoli

时间篡改

Solidity中,block.timestamp是和矿工确认交易有关的。如果攻击方作为矿工确认交易的情况下,攻击方可以通过控制block.timestamp来影响依赖于block.timestamp的合约。(block.timestamp等同于now)

1
2
3
4
5
function play() public {
require(now > 1521763200 && neverPlayed == true);
neverPlayed = false;
msg.sender.transfer(1500 ether);
}

这段代码只接受固定时间的交易。由于矿工可以影响区块的时间戳,他们可以尝试挖掘包含了该合约的区块来设定未来区块的时间戳。只要时间戳足够接近,交易将会被接受,从而攻击者(矿工)获得收益。

Short Address Attack

1
2
3
The service preparing the data for token transfers assumed that users will input 20-byte long addresses, but the length of the addresses was not actually checked.

-- Paweł Bylica

短地址攻击:由于EVM在处理数据时的参数对齐原则,攻击者可以输入较短的地址,窃取_amount的数位,从而使_amount的数值扩大。

假如Bob要求Alice转给他20个货币,Bob的地址为:0x3bdde1e9fbaef2579dd63e2abbf0be445ab93f00

正常的msg.data为:

1
2
3
4
0xa9059cbb  -> bytes4(keccak256("transfer(address,uint256)"))
0000000000000000000000003bdde1e9fbaef2579dd63e2abbf0be445ab93f00 --> address
0000000000000000000000000000000000000000000000000000000000000014 --> _amount
(添0补齐32位)

但是Bob将自己地址末尾的00抹去,将0x3bdde1e9fbaef2579dd63e2abbf0be445ab93f告诉了Alice。

此时的msg.data为:

1
2
3
0xa9059cbb  -> bytes4(keccak256("transfer(address,uint256)"))
0000000000000000000000003bdde1e9fbaef2579dd63e2abbf0be445ab93f --> address
0000000000000000000000000000000000000000000000000000000000000014 --> _amount

由于EVM参数对其,将msg.data变为:

1
2
3
4
0xa9059cbb  -> bytes4(keccak256("transfer(address,uint256)"))
0000000000000000000000003bdde1e9fbaef2579dd63e2abbf0be445ab93f00 --> address
0000000000000000000000000000000000000000000000000000000000001400 --> _amount
(address从_mount中获取两位,并在_mount最后两位补0)

所以,_amount的数目从0x14变成了0x1400,实现了短地址攻击。

具体攻击过程:(前提是交易所不检查用户输入的地址)

  1. 给自己生成一个末尾为00的账号
  2. 找到一个交易所钱包,转入0x14个货币,同时把自己地址末尾的00去掉
  3. 参考上述的数据变化过程,交易所会认为你存入了0x1400个货币,然后我们就可以取出0x1400个货币。甚至把交易所钱包里面的货币取空。

Unknown Unknowns

1
2
3
We believe more security audits or more tests would have made no difference. The main problem was that reviewers did not know what to look for.

-- Christoph Jentzsch

Unexpected Ether

通常,当以太币发送到合约时,合约会执行fallback()或者合约中其他功能。

但是会有以太币被强制发送到合约来影响合约代码执行的攻击。

比如下面这样一个合约:

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
contract EtherGame {

uint public payoutMileStone1 = 3 ether;
uint public mileStone1Reward = 2 ether;
uint public payoutMileStone2 = 5 ether;
uint public mileStone2Reward = 3 ether;
uint public finalMileStone = 10 ether;
uint public finalReward = 5 ether;

mapping(address => uint) redeemableEther;
// users pay 0.5 ether. At specific milestones, credit their accounts
function play() public payable {
require(msg.value == 0.5 ether); // each play is 0.5 ether
uint currentBalance = this.balance + msg.value;
// ensure no players after the game as finished
require(currentBalance <= finalMileStone);
// if at a milestone credit the players account
if (currentBalance == payoutMileStone1) {
redeemableEther[msg.sender] += mileStone1Reward;
}
else if (currentBalance == payoutMileStone2) {
redeemableEther[msg.sender] += mileStone2Reward;
}
else if (currentBalance == finalMileStone ) {
redeemableEther[msg.sender] += finalReward;
}
return;
}

function claimReward() public {
// ensure the game is complete
require(this.balance == finalMileStone);
// ensure there is a reward to give
require(redeemableEther[msg.sender] > 0);
redeemableEther[msg.sender] = 0;
msg.sender.transfer(redeemableEther[msg.sender]);
}
}

玩家将0.5以太币发送给合约,有希望最先完成三个MileStone从而从合约中获得10以太币的奖励。

如果攻击者使用之前提到的selfdestruct给本合约强制转账0.1以太币,将无法让任何玩家达到MileStone

如果攻击者强制发送10以太币甚至更多,合约将始终处于claimReward状态,从而导致合约中所有以太币被锁定。

Tx.Origin Authentication

Solidity有一个全局变量tx.origin,它遍历整个调用堆栈并返回最初调用它的账户地址。所以在智能合约中用此变量进行身份验证很容易受到攻击。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract Phishable {
address public owner;

constructor (address _owner) {
owner = _owner;
}

function () public payable {} // collect ether

function withdrawAll(address _recipient) public {
require(tx.origin == owner);
_recipient.transfer(this.balance);
}
}

该合约使用tx.origin函数进行身份验证.

如果构造这样一个攻击合约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import "Phishable.sol";

contract AttackContract {

Phishable phishableContract;
address attacker; // The attackers address to receive funds.

constructor (Phishable _phishableContract, address _attackerAddress) {
phishableContract = _phishableContract;
attacker = _attackerAddress;
}

function () {
phishableContract.withdrawAll(attacker);
}
}

如果Phishable合约向攻击合约发送了以太币,就会发生Phishable合约中owner的转移。

参考链接

DASP Top 10

以太坊智能合约安全入门了解一下(上)

以太坊智能合约安全入门了解一下(下)

sigmaprime‘s blog