智能合约最佳实践翻译三

已知攻击

以下列出了你应该知道的已知攻击,并且在编写智能合约的时候进行防御。

竞争条件(Race Conditions)

调用外部合约的一个主要危险之一是它会接管控制流,并且修改调用方法不被 期望修改的数据。这种类型的 bug 有很多形式,导致 DAO 崩溃的两个主要错误都是这类错误。

重入(Reentrancy)

这个 bug 的第一个版本需要注意,这个函数可以在函数第一次调用完成之前重复调用。 这可能会导致函数的不同调用以破坏性方式进行交互。

1
2
3
4
5
6
7
8
9
// insecure

mapping(address => uint) private userBalances;

function withdrawBalance() public {
uint amountToWithdraw = userBalances[msg.sender];
require(msg.sender.call.value(amountToWithdraw)()); // At this point, the call's code is executed, and can call withdrawBalance again
userBalances[msg.sender] = 0;
}

因为用户余额直到最后被提取完才会被设置成 0,所以第二次(和更之后)的调用仍然会成功,从而一次又一次地提取余额。一个非常类似的 bug 是 DAO 攻击中的一个漏洞。

在给出的例子中,避免这个问题的最好的办法是通过 使用 send() 代替 call.value()()。这会防止执行中的任何外部代码。

然而,如果你不能移除外部调用,下一个简单地防止此攻击的方法是确保直到你完成了所有的内部工作之前都没有调用外部函数,你需要做:

1
2
3
4
5
6
7
mapping(address => uint) private userBalances;

function withdrawBalance() public {
uint amountToWithdraw = userBalances[msg.sender];
userBalances[msg.sender] = 0;
require(msg.sender.call.value(amountToWithdraw)());
}

注意,如果你有另一个调用了 withdrawBalance()的函数,它可能会受到相同的攻击,所以你必须对调用了不可信合约的函数也视为不可信。请参阅下方的潜在解决方案的进一步讨论。

函数交叉竞争条件(Cross-function Race Conditions)

一个攻击者可能使用通过两个不同的函数共享一个状态来进行类似的攻击。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// insecure

mapping(address => uint) private userBalances;

function transfer(address to, uint amount) {
if (userBalances[msg.sender] >= amount) {
userBalance[to] += amount;
userBalance[msg.sender] -= amount;
}
}

function withdrawBalance() public {
uint amountToWithdraw = userBalances[msg.sender];
require(msg.sender.call.value(amountToWithdraw)()); // At this point, the caller's code is executed, and can call transfer()
userBalances[msg.sender] = 0;
}

在这个例子中,当攻击者的代码在 withdrawBalance() 中的外部调用中被执行,攻击者会调用 transfer()。只要他们的余额不是 0,那么他们就可以在已经收到过一次提币的情况下,再次转移 token。这个漏洞也在 DAO 攻击中被使用。

在相同的警告下,相同的解决方案也会起作用。也要注意,在这个例子中,函数都是同一个合约的一部分。但是,如果多个合约共享状态,那么相同的 bug 也可以出现在交叉的多重合约中。

竞争条件解决方案中的陷阱

因为竞争条件可以在多个函数,甚至多个合约中发生,所以任何旨在防止重入的解决方案都是不够的。

相反,我们推荐在完成全部的内部工作之后再调用外部函数。如果你小心地遵循了这个规则,就可以避免竞争条件。但是,你不止要避免太早调用外部函数,也要避免调用了外部函数的函数。举例来说,下面是不安全的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// insecure
mapping(address => uint) private userBalances;
mapping(address => bool) private claimedBonus;
mapping(address => uint) private rewardsForA;

function withdraw(address recipient) public {
uint amountToWithdraw = userBalances[recipient];
rewardsForA[recipient] = 0;
require(recipient.call.value(amountToWithdraw)());
}

function getFirstWithdrawalBonus(address recipient) public {
require(!claimedBonus[recipient]); // Each recipient should only be able to claim the bonus once


rewardsForA[recipient] += 100;
withdraw(recipient); // At this point, the caller will be able to execute getFirstWithdrawalBonus again.
claimedBonus[recipient] = true;
}

即使 getFirstWithdrawalBonus() 没有直接调用外部合约,withdraw() 内的调用已经足够产生一个竞争条件的漏洞。因此你不可以信任 withdraw() 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
mapping(address => uint) private userBalances;
mapping(address => bool) private claimedBonus;
mapping(address => uint) private rewardsForA;

function untrustedWithdraw(address recipient) public {
uint amountToWithdraw = userBalances[recipient];
rewardsForA[recipient] = 0;
require(recipient.call.value(amountToWithdraw)());
}

function untrustedGetFirstWithdrawalBonus(address recipient) public {
require(!claimedBonus[recipient]); // Each recipient should only be able to claim the bonus once

claimedBonus[recipient] = true;
rewardsForA[recipient] += 100;
untrustedWithdraw(recipient); // claimedBonus has been set to true, so reentry is impossible
}

除了修复重入的问题,不受信任的函数也被标记了。这个模式在每个层级都被重复使用:既然 untrustedGetFirstWithdrawalBonus() 调用了调用了外部合约的 untrustedWithdraw(),那么你就必须将 untrustedGetFirstWithdrawalBonus() 视作不安全的。

另一个经常建议的解决方案是一个 互斥。它允许你 “锁定” 某些状态,所以状态只能被锁的拥有者改变。一个简单的例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Note: This is a rudimentary example, and mutexes are particularly useful where there is substantial logic and/or shared state
mapping(address => uint) private balance;
bool private lockBalances;

function deposit() payable public returns (bool) {
require(!lockBalances);
lockBalances = true;
balances[msg.sender] += msg.value;
lockBalances = false;
return true;
}

function withdraw(uint amount) payable public returns (bool) {
require(!lockBalances && amount > 0 && balances[msg.sender] >= amount);
lockBalances = true;

if (msg.sender.call(amount)()) {
balances[msg.sender] -= amount;
}

lockBalances = false;
return true;
}

如果用户试图在第一个调用结束前再次调用 withdraw(),锁就会防止其作用。这是一个有用的方式,但是当你有多个合约合作的时候,就会有一些困难。下面是不安全的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// insecure

contract StateHolder {
uint private n;
address private lockHolder;

function getLock() {
require(lockHolder == 0);
lockHolder = msg.sender;
}

function releaseLock() {
require(msg.sender == lockHolder);
lockHolder = 0;
}

function set(uint newState) {
require(msg.sender == lockHolder);
n = newState;
}
}

一个攻击者可以调用 getLock(),之后就永远不用调用 releaseLock()。如果他们这么干了,那么合约将被永远锁定,无法进行任何修改。如果你使用互斥来防止竞争条件,那么你需要小心地确认不会出现只能锁定而不能释放的情况。(使用互斥编程也有其他潜在的危险,如死锁和活锁。如果你想要使用这种方式,你应该查阅咨询大量互斥相关的文献资料。)

有些人可能会反对使用术语竞争条件,因为以太坊目前没有真正的并行性。 然而,逻辑上不同的进程争夺资源的基本特征仍然存在,同样的陷阱和潜在的解决方案也适用。

交易顺序依赖(TOD)/ 前台运行

以上的竞争条件示例涉及到攻击者在单笔交易中执行恶意代码。以下是区块链固有的一种不同类型的竞争条件:交易本身(在区块内)的顺序容易受到操纵。

在被打包进区块之前,交易会进入内存池一段时间,因此可以知道交易在被打包之前有什么行为。对于像去中心化市场这类对象来说,这可能会有些麻烦,在这种市场中,可能看到买入 token 的交易,和其他交易被包含之前的市场顺序实现形式。防止这种情况很困难,因为它会涉及到具体合约本身。举例来说,在市场中,实施批量拍卖会更好(这也可以防止高频交易问题)。另一种方法是使用预先提交方案(“我将在稍后提交细节”)。

时间戳依赖

注意区块的时间戳可以被矿工操作,所以在不管是直接还是间接的对时间戳的使用中要多加考虑。

有关与时间戳相关的设计注意事项,请参阅 建议 部分。

整数溢出和下溢

考虑一个简单的 token 转移:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
mapping(address => uint256) public balanceOf;

// insecure
function transfer(address _to, uint256 _value) {
// check if sender has balance
require(balanceOf[msg.sender] >= _value);
// add and subtract new balances
balanceOf[msg.sender] -= _value;
balanceOf[_to] += _value;
}

// secure
function transfer(address _to, uint256 _value) {
// check if sender has balance and for overflows
require(balanceOf[msg.sender] >= _value && balanceOf[_to] + _value >= balanceOf[_to]);

// add and subtract new balances
balanceOf[msg.sender] -= _value;
balanceOf[_to] += _value;
}

如果余额到达了 uint 值(2^256)的最大值,它会被置为零。这是检查这个条件。这可能有关也可能无关,取决于实现方式。考虑一下 uint 值是否有这个机会达到这么大的一个数字。考虑 uint 变量是如何改变状态的,并且谁有权限来做这样的改变。如果任意用户都可以更新 uint 值,那么就会有很多的攻击漏洞。如果只有一个管理者拥有权限来修改变量的状态,那你可能是安全的。如果一个用户每次尽能增加 1,那你也可能是安全的,因为并没有可行的方式来达到限制。

对于下溢来说也是一样的。如果一个 uint 被设置成小于 0,这就会导致一个下溢然后被设置成它的最大值。

对 uint8, uint16, uint24… 等更小的数据类型要小心:它们会更容易的达到它们的最大值。

注意这里有大约 20 个关于溢出和下溢的例子

(不期望的)回滚导致的 DoS

考虑一个简单的拍卖合约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// insecure

contract Auction {
address currentLeader;
uint highestBid;

function bid() payable {
require(msg.value > highestBid);

require(currentLeader.send(highestBid)); // Refund the old leader, if it fails then revert

currentLeader = msg.sender;
highestBid = msg.value;;
}
}

当它试图退款给用户的时候,当退款失败,它会回滚。意思是一个恶意的竞标者可以通过让它们的退款总是失败而变成领导者。在这个方式中,它们可以防止其他任何人调用 bid() 函数,然后保证自己一直都是领导者。一个推荐是如之前所述,设置一个 拉取支付系统 来替代。

另一个例子是当一个合约想要通过便利一个数组来支付给用户(如,在众筹合约中的支持者)。通常都想要确保每一笔支付都成功。如果没有的话,就回滚。这个问题是如果一个调用失败了,你就会回滚整个支付系统,那么循环将永远不会完成。因为一个地址强制错误所以没有人获得报酬。

1
2
3
4
5
6
7
8
9
address[] private refundAddresses;
mapping(address => uint) public refunds;

// bad
function refundAll() public {
for (uint x; x < refundAddresses.length; x++) { // // arbitrary length iteration based on how many addresses participated
require(refundAddresses[x].send(refunds[refundAddresses[x]])) // doubly bad, now a single failure on send will hold up all funds
}
}

再一次,推荐的解决方法是 赞成拉推支付

区块燃料限制导致的 DoS

你可能注意到了之前的例子中的另一个问题:一次性支付给所有人,你会有触及区块燃料限制的风险。每一个以太坊区块都能处理一个确定的最大算力值。如果你想要超过它,那你的交易就会失败。

即使没有故意的攻击,这也会造成问题。但是,如果攻击者可以操纵所需的燃料值,就会特别糟糕。在之前的例子中,攻击者可以添加大量地址,每一个都需要获得极销量的退款。攻击者的每一个地址退款操作的燃气消耗最终可能超过燃料限制,从而阻止退款交易的发生。

这是另一个 赞成推拉支付 的原因。

如果你必须完全遍历一个你不知道大小的数组,那么你应该计划它可能需要多个区块,因而需要多个交易。你会需要对你当前的进度保持跟踪,并且能够从那个点恢复,就像下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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() 函数的下一个遍历的时候,如果其他交易被处理,不会有不好的事情发生。所以只在完全必要的时候使用这个模式。

强制将 ETH 发送到合约

可以不通过触发合约的 fallback 函数来强行将 ETH 发送到合约。当 fallback 函数内有重要的逻辑或者进行了基于合约余额的计算时,这是一个很重要的考虑。见下面的例子:

1
2
3
4
5
6
7
8
9
10
contract Vulnerable {
function () payable {
revert();
}

function somethingBad() {
require(this.balance > 0);
// do something bad
}
}

合约逻辑看起来并不允许合约被支付,因此也不允许 “一些坏事” 发生。但是,存在一些强制发送 ETH 给合约的方法,因此它的余额大于 0。

合约方法 selfdestruct 允许用户指定一个受益人发送多余的 ETH。selfdestruct 不会触发合约的 fallback 函数

也可以预先计算合约的地址,然后在合约部署之前给那个地址发送 ETH。

合约开发者应该注意 ETH 可以被强制发送给合约并且应该设计相应的合约逻辑。通常来说,假设不可能限制你的合约的资金来源。

废弃的 / 历史的攻击

这些攻击由于协议的改变或固体的改进而不再可能发生。

调用深度攻击(废弃)

在 EIP 150 的硬分叉中,调用深度攻击不再相关*(在达到 1024 调用深度限制之前,所有气体都将消耗得很好)。