上次我们谈到了由于solidity智能合约代码的公开性和行业现状,这一领域的安全状况令人堪忧,所幸目前这种情况正在被重视,有些组织和个人已经做了一些有意义的实践与总结。下面是solidity社区总结出的安全方面的主要关注点与应对思路。
智能合约的安全问题与最佳实践
1. 重入
如果一个智能合约A调用了另一个智能合约B,那么控制权将从A完全传递给B,也就是说,如果B再回调回A也,然后再调用B,然后再调用A...这样循环下去,A也是没有办法的。下面的合约将允许一个攻击者多次得到退款,因为它使用了 call ,默认发送所有剩余的 gas:
pragma solidity ^0.4.0;// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
/// Mapping of ether shares of the contract.
mapping(address => uint) shares; /// Withdraw your share.
function withdraw() public {
if (msg.sender.call.value(shares[msg.sender])())
shares[msg.sender] = 0;
}
}
作为改善措施,应该使用使用“检查-生效-交互”(Checks-Effects-Interactions)模式编写函数代码:
第一步,做检查工作,例如参数是否合法,发送者是否合法...,这些检查工作应该首先被完成。
第二步,状态变量修改。
第三步,与其他合约交互,这一步在任何合约中都应该最后被调用。
由于对已知合约的调用反过来也可能导致对未知合约的调用,所以最好是一直保持使用这个模式编写代码。按照这一模式,以上代码应该这么写:
pragma solidity ^0.4.11;
contract Fund { /// 合约中 |ether| 分成的映射。
mapping(address => uint) shares; /// 提取你的分成。
function withdraw() public {
var share = shares[msg.sender];
shares[msg.sender] = 0;
msg.sender.transfer(share);
}
}
2.address.send(), address.transfer() vs address.call.value()()
这三个调用都可以用来向一个智能合约转账,但仍然有以下区别:
address.send()和address.transfer()是重入安全(可以防止重入)的. 原因是,这两种调用只分配到了2300 gas,这一数量一般只够记录一两条log。所以,当被调用的地址是一个合约,而且实现了可以被外界执行的默认函数,那么这个默认函数仍然会被调用到。address.transfer(y)效果等同于require(address.send(y));.
address.call.value(y)()将会把所有的gas发送到合约地址上并执行默认函数. 所以这个默认函数将会有足够的gas执行任何操作,包括重新调用原合约的接口,因此是重入不安全(不能防止重入)的。
因此,在用这三个接口转账时,一定要根据实际情况把安全状况考虑清楚。一般来说,首选address.send()和 address.transfer()。其次选address.call.value(y)(),在使用address.call.value(y)()的时候最好限定所能使用的最多gas。
3.push模式 vs pull模式
在以上的讨论中,尽管你使用address.call.value(y)()的时候限定了所能使用的最多gas,仍然不意味着就没有问题了,因为也许一个你没有想到的漏洞所需要的gas本来就低于你的限定量。我们最好将调用限制到智能运行其本身的transaction,因此,在付款相关操作中,拉取模式(pull)应该优先于推送模式(push), 让我们看看以下例子:
// bad
contract auction {
address highestBidder;
uint highestBid;
function bid() payable {
require(msg.value >= highestBid);
if (highestBidder != 0) {
highestBidder.transfer(highestBid); // if this call consistently fails, no one else can bid
}
highestBidder = msg.sender;
highestBid = msg.value;
}
}
以上例子中,退款采用的是push模式,如果有一个攻击者在fallback中调用require(0);然后参与以上拍卖之后,其他人再参与拍卖时以上代码highestBidder.transfer(highestBid);便会失败。从而达到锁定拍卖的目的。而如果退款时采取pull模式,则会避免这一缺陷:
// good
contract auction {
address highestBidder;
uint highestBid;
mapping(address => uint) refunds;
function bid() payable external {
require(msg.value >= highestBid);
if (highestBidder != 0) {
refunds[highestBidder] += highestBid; // record the refund that this user can claim
}
highestBidder = msg.sender;
highestBid = msg.value;
}
function withdrawRefund() external {
uint refund = refunds[msg.sender];
refunds[msg.sender] = 0;
msg.sender.transfer(refund);
}
}
4.合约间调用处理
Solidity 开放了一些底层调用函数如 address.call(),address.callcode(),address.delegatecall()和 andaddress.send(). 这些底层调用的一个特点是:他们调用失败时不会抛出异常,只是会返回失败,而异常在合约间的调用中是可以传递的,返回的失败结果却不能。因此,当调用这些底层函数时一定要考虑调用失败的情形,例如,我们不能这么写代码:
// bad
someAddress.send(55);
someAddress.call.value(55)();
someAddress.call.value(100)(bytes4(sha3("deposit()")));
而应该这么写:
// good
if(!someAddress.send(55)) {
// Some failure code
}
ExternalContract(someAddress).deposit.value(100);
5.不要假设智能合约里没有eth或者其他代币
攻击者甚至可以在你的合约没有创建之前向你的智能合约转入一笔eth。当然也有一些其他办法巧妙的向你的智能合约存入eth,例如:攻击者可以创建一个合约,向这个合约中转入少量eth,然后调用selfdestruct(victimAddress),这个victimAddress填成你的合约地址,这样eth就转到你的合约里了,这种方法没有人能阻止。
事实上,除了以上基本准则,人们还在不断总结经验并抽象出一些代码模式。相信在人们的共同努力下,solidity智能合约会越来越安全。