以太坊合约开发最佳安全指南与反模式

安全

以太坊智能合约 —— 最佳安全开发指南

最佳实践中所体现出的这些特性,其实是所有软件开发中都需要的;而在智能合约代码中尤其重要是因为合约一旦部署无法再做任何更改。

  • 最小化/简单化
  • 代码重用
  • 代码质量
  • 可读性和可审计性
  • 测试覆盖率

安全风险和反模式

有漏洞合约的例子

重入

最有名的例子就是The DAO,code:

1
2
3
4
5
6
7
8
9
10
11
12
13
function getBalance(address user) constant returns(uint) {
return userBalances[user];
}

function addToBalance() {
userBalances[msg.sender] += msg.amount;
}

function withdrawBalance() {
amountToWithdraw = userBalances[msg.sender];
if (!(msg.sender.call.value(amountToWithdraw)())) { throw; }
userBalances[msg.sender] = 0;
}
防范技术
  1. 使用transfer方法转账,因为transfer方法转账时只会带2300gas,这些gas不足以让其他合约再做其他的操作
  2. 在转账之前先修改状态(检查-修改-交互)

算数溢出

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: GPL-3.0

pragma solidity=0.8.0;

contract TimeLock {
mapping(address => uint) public balances;

mapping(address => uint) public lockTime;

function deposit() public payable {
balances[msg.sender] += msg.value;
lockTime[msg.sender] = block.timestamp + 1 weeks;
}

function increaseLockTime(uint _secondsToIncrease) public {
lockTime[msg.sender] += _secondsToIncrease;
}

function withdraw() public {
require(lockTime[msg.sender] < block.timestamp);
require(balances[msg.sender] > 0);
balances[msg.sender] = 0;
payable(msg.sender).transfer(balances[msg.sender]);
}

}
防范技术

使用SafeMath库

意外的以太币

有两种方式在合约没有payable方法时也能够强制给合约转入以太币:

  • self-destruct:在一个合约使用selfdestruct函数将代码清除时,可以把需要自毁的合约中的以太币转入到指定的地址
  • 预先发送的以太币:合约的地址是根据创建这个合约的账户地址和交易nonce通过Keccak256计算出来的,所以其实是可以预先给一个合约发送以太币的
这会导致什么问题呢?

好像有人给我的合约发送以太币并不是什么坏事,不是吗?

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
// SPDX-License-Identifier: GPL-3.0

pragma solidity=0.8.0;

contract EtherGame {
uint public constant three = 3;
uint public constant six = 6;
uint public constant nine = 9;

uint public constant finalAmount = 10;

mapping(address => uint) public redeemableEther;

function play() public payable {
require(msg.value == 0.5 ether);
uint currentBalance = address(this).balance + msg.value;
require(currentBalance <= finalAmount);
if (currentBalance == three || currentBalance == six || currentBalance == nine) {
redeemableEther[msg.sender] += currentBalance;
}
}

function getRedeem() public {

require(address(this).balance == finalAmount);
uint transferValue = redeemableEther[msg.sender];
require(transferValue > 0);
redeemableEther[msg.sender] = 0;
payable(msg.sender).transfer(transferValue);
}

}
防范技术

慎用this.balance;如上面的合约可以改为:

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
// SPDX-License-Identifier: GPL-3.0

pragma solidity=0.8.0;

contract EtherGame {
uint public constant three = 3;
uint public constant six = 6;
uint public constant nine = 9;

uint public constant finalAmount = 10;

uint public depositedWei;

mapping(address => uint) public redeemableEther;

function play() public payable {
require(msg.value == 0.5 ether);
uint currentBalance = depositedWei + msg.value;
require(currentBalance <= finalAmount);
if (currentBalance == three || currentBalance == six || currentBalance == nine) {
redeemableEther[msg.sender] += currentBalance;
}
depositedWei += msg.value;
}

function getRedeem() public {
require(depositedWei == finalAmount);
uint transferValue = redeemableEther[msg.sender];
require(transferValue > 0);
redeemableEther[msg.sender] = 0;
payable(msg.sender).transfer(transferValue);
}


}

DELEGATECALL

DELEGATECALL调用是使用当前的上下文调用目标合约,并且会以主调用合约的状态来运行。也就是目标合约的状态存储槽的数据会变成主调用合约的状态存储槽的数据。

防范技术

DELEGATECALL调用时要非常仔细的注意调用的上下文,并且尽可能的构建无状态的库合约

默认的可见性

  • 函数的默认可见性是public
  • 状态变量默认可见性是internal

所以如果一个函数忘记了写可见性关键字,则所有人(包括外部账户和合约账户)都可以调用这个函数。

防范技术

为合约中的所有函数都明确指定可见性是最佳实践。

无序错觉

以太坊内部几乎没有随机性,如果合约通过判断未来的区块大小、hash、gas上限、时间戳等来进行状态的变更;这些变量都可能被矿工所控制。

防范技术

使用外部的oracle最为随机源来保证随机性。如RANDAO

外部合约引用

如果一个合约要引用其他的外部合约,如何引用是一个问题;如果在构造方法中通过输入地址来引用,则会有可能因为输错地址而导致引用错误。

蜜罐合约:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
/**
*Submitted for verification at Etherscan.io on 2018-02-09
*/

pragma solidity ^0.4.19;

contract Private_Bank
{
mapping (address => uint) public balances;

uint public MinDeposit = 1 ether;

Log TransferLog;

function Private_Bank(address _log)
{
TransferLog = Log(_log);
}

function Deposit()
public
payable
{
if(msg.value >= MinDeposit)
{
balances[msg.sender]+=msg.value;
TransferLog.AddMessage(msg.sender,msg.value,"Deposit");
}
}

function CashOut(uint _am)
{
if(_am<=balances[msg.sender])
{

if(msg.sender.call.value(_am)())
{
balances[msg.sender]-=_am;
TransferLog.AddMessage(msg.sender,_am,"CashOut");
}
}
}

function() public payable{}

}

contract Log
{

struct Message
{
address Sender;
string Data;
uint Val;
uint Time;
}

Message[] public History;

Message LastMsg;

function AddMessage(address _adr,uint _val,string _data)
public
{
LastMsg.Sender = _adr;
LastMsg.Time = now;
LastMsg.Val = _val;
LastMsg.Data = _data;
History.push(LastMsg);
}
}
防范技术
  1. 在构造函数中使用new的方法来引用其他合约
  2. 在引用其他合约时对外部合约地址进行硬编码(就算硬编码也不能100%完全的不输错,但是这很容易审查)

短地址/参数攻击

漏洞的细节

当我们向智能合约传递参数时,这些参数需要依照ABI规范进行编码。不过发送的实际数据长度小于标准的参数编码长度也是可以的。在这种情况下,EVM会在数据的末尾补0来使数据长度达到要求。

防范技术

所有的外部应用在把输入参数发送到区块链之前都应该对他们进行校验。

未检查的调用返回值

在使用call或者send进行外部调用时,这两个方法会返回一个bool类型的结果,如果不检查这个结果就继续往下进行,有可能导致callsend的调用失败,而之后的代码执行成功。(很像本应该在同一个事物中执行的两个操作,第一个操作失败了,第二个操作却成功了,最终导致有问题。)

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
// SPDX-License-Identifier: GPL-3.0

pragma solidity=0.8.0;

contract Lotto {

address public winner;

bool public payedOut = false;

uint public winAmount;

function play() public payable {
winAmount += msg.value;
if(msg.sender == address(0x2560be5793F9AA00963e163A1287807Feb897e2F)) {
winner = msg.sender;
}
}

function sendToWinner() public {
require(!payedOut);
payable(winner).send(winAmount);
payedOut = true;
}

function withdrawLeftOver() public {
require(payedOut);
payable(msg.sender).transfer(winAmount);
}

}
防范技术

尽可能的使用transfer函数而不是send,因为transfer函数会在外部调用失败时revert。

竞争条件/预先交易

在目前Pow的共识方式下,以太坊网络上的所有的交易都是由矿工从交易池中选择部分交易打包成区块的,而矿工在打包区块的时候会优先打包gas费多的交易。因此如果一个人监听了交易池中某个合约(如猜谜语)的交易,那么他在看到别人猜中的答案时马上发布一个gas费更高的同样答案的交易就很有可能会提前被确认。

防范技术

能够发起这种攻击的人有两种:

  1. 普通用户通过提高交易费的方式进行攻击
  2. 矿工自己对交易进行攻击

第一种防范方法是设置gas费的上限,这样就能一定程度的避免用户通过提交交易费来进行提前确认。但是这种方式无法防范矿工。

第二种防范方法是(提交-揭示),即先提交一个答案的hash,然后再提交答案。

拒绝服务

基于可被外部操纵的映射或数组的循环
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
// SPDX-License-Identifier: GPL-3.0

contract DistributeTokens {

address public owner;

address[] investors;

uint[] investorTokens;

function inverst() public payable {
investors.push(msg.sender);
investorTokens.push(msg.value * 5);
}

modifier onlyOwner() {
require(address(msg.sender) == owner);
_;
}

function distribute() public onlyOwner {
for (uint i = 0; i < investors.length; i++) {
transferToken(investors[i], investorTokens[i]);
}
}

}

主人的操作

如果一个合约只能由合约的主人做一些操作才能进入下一个状态,那么如果合约的主人失去了行为能力或者丢失了私钥那么这个合约就永远无法进入下一个状态了。

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
// SPDX-License-Identifier: GPL-3.0

pragma solidity=0.8.0;

contract ICO {

bool public isFinalized = false;

address public owner;

constructor () {
owner= msg.sender;
}

mapping(address => uint) public monery;

function giveMe() public payable {
monery[msg.sender] += msg.value;
}

function finalize() public {
require(msg.sender == owner);
isFinalized = true;
}

function withdraw() public {
require(isFinalized);
payable(msg.sender).transfer(monery[msg.sender]);
}

}
基于外部调用来修改状态

如果一个合约只有把余额转移到外部的某个账户时才能够更改状态,那么外部的那个账户如果没有fallback之类的用于接收以太币的函数的话,当前的函数永远都无法进入下一个状态。

防范技术

第一个例子,合约不应该基于一个可以被外部用户人为操纵的数据结构来执行循环。推荐使用取回模式,让每个取款人单独的调用withdraw函数来取回他们各自的代币。

第二个例子,将主人设定为多重签名合约;或者是使用时间锁(require(msg.sender ==owner || block.timestamp > unlockTime))。

区块时间戳操纵

下面的代码,如果时间戳是15的倍数则能取走当前合约的所有余额。但是时间戳是可以被矿工轻微的调整的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// SPDX-License-Identifier: GPL-3.0

pragma solidity=0.8.0;

contract Roulette {
uint public pastBlockTime;

constructor() payable {

}

fallback() external payable {
require(msg.value == 10 ether);
require(block.timestamp != pastBlockTime);
pastBlockTime = block.timestamp;
if (pastBlockTime % 15 == 0) {
payable(msg.sender).transfer(address(this).balance);
}
}
}

防范技术

区块的时间戳不应该被用来作为无序数据或生成随机数,也就是说,他们不应该用来作为游戏的获胜条件或者用来判断一个重要的状态变动。

如果合约需要感知时间的逻辑,如解锁合约(基于时间锁),在ICO开始几周之后来结束它或者强制指定一个过期时间。有些情况推荐使用block.number和平均区块时间来估算时间条件。

小心使用构造函数

在Solidity v0.4.22之前,和合约名称相同的函数就是构造函数。如果合约修改了名称但是忘了改构造函数的名称,那么这个函数就变成了一个普通的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// SPDX-License-Identifier: GPL-3.0

pragma solidity=0.4.1;

contract OwnerWallet {
address public owner;

function ownerWallet() {
owner = msg.sender;
}

function () public payable {

}

function withdraw() public {
require(msg.sender == owner);
msg.sender.transfer(this.balance);
}

}
防范技术

Solidity v0.4.22之后的版本的构造函数是通过constructor关键字生命,Solidity的版本变化较快,尽量选择较新的稳定版本。

未初始化的存储指针

EVM是用存储(storaege)或内存(memory)来保存数据的。函数中轭局部变量默认在存储中还是在内存中,取决于他们的类型。

// TODO 存储指针这一块还没有搞懂,后续再补充。

浮点数和精度

下面的合约buyTokens方法在支付的以太币小于1ether时,得到的token会是0;或者说会把小于1ether的部分全部舍去导致出错。

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: GPL-3.0

pragma solidity=0.8.0;


contract FunWithNumbers {

uint constant public tokensPerEth = 10;

uint constant public weiPerEth = 1e18;

mapping(address => uint) public balances;


function buyTokens() public payable {
uint tokens = msg.value / weiPerEth * tokensPerEth;
balances[msg.sender] = tokens;
}

function sellTokens(uint tokens) public {
require(tokens < balances[msg.sender]);
balances[msg.sender] -= tokens;
payable(msg.sender).transfer(tokens / tokensPerEth * weiPerEth);
}

}

Tx.Origin

通过tx.origin变量来判断用户授权的合约一般是易受钓鱼攻击的,这种攻击是通过欺骗用户向有漏洞的合约发送需要授权的操作来实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// SPDX-License-Identifier: GPL-3.0

pragma solidity = 0.8.0;

contract Phishable {

address public owner;

constructor () {
owner = msg.sender;
}

receive() external payable{
}

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

}

攻击合约如下,如果有人通过做游戏等方式让Phishable合约的owner给AccackContract合约转一小部分以太币,那么这个owner会损失Phishable合约中的所有以太币。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// SPDX-License-Identifier: GPL-3.0

pragma solidity = 0.8.0;


import "Phishable.sol";

contract AccackContract {

Phishable phishableContract;

address attacker;

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


fallback() external payable {
phishableContract.withdrawAll(_attackerAddress);
}

}
防范技术

智能合约中不应该使用tx.origin来进行验证授权。

tx.origin的使用场景:如果某人想要拒绝外部合约调用当前合约,他们可以实现一个类似require(tx.origin == msg.sender);这样的检查;这样可以防止当前合约被其他中间合约调用,也就是仅允许外部账户调用当前合约。