如果你是以太坊开发新人,我建议你在读这篇文章之前先读一下我们的Hitchhiker的以太坊智能合约指导。
学习以太坊智能合约安全性是一项非常艰难的工作。只有很少的优秀指导和编辑物,例如ConsenSys的《智能合约最佳实践》( Smart Contracts Best Practices),或者《Solidity文档安全性注意事项》。但是如果你不亲自动手编写代码,那么这些概念是很难记住并内在化。
我会尝试一种稍微不同的方法。我会解释一些被推荐的策略来提高智能合约的安全性并展示一些代码示例。我还会展示一些你能用来保护你的智能合约的代码样本。希望这些能够帮助创建一种对事物的肌肉记忆,作为今后编写实际代码的一种精神警告。
事不宜迟,让我们进入最佳实践:
代码失败要尽快,并且大声谈论失败
一个简单且强大的良好编程习惯做法是:使你的代码失败尽可能的快。并大声谈论它。让我们看看一个表现羞怯的函数例子:
// UNSAFE CODE, DO NOT USE!
contract BadFailEarly { uint constant DEFAULT_SALARY = 50000; mapping(string => uint) nameToSalary;
function getSalary(string name) constant returns (uint) { if (bytes(name).length != 0 && nameToSalary[name] != 0) { return nameToSalary[name]; } else { return DEFAULT_SALARY; } } }
我们希望悄悄地避免一个合约失败,或者以一种不稳定或不一致的状态继续执行。函数getSalary在返回存储的salary之前正在检查条件,这一点很不错。问题是,当这些条件无法满足时,返回的是一个默认值。这就可能会隐藏调用方的错误。这是在一种极端的情况下,但这种编程是很常见的,通常这是对错误的恐惧,但这种恐惧会破坏我们的app。事实是,我们越早失败,就越容易找到问题。如果我们隐藏错误,它们可以传播到代码的其他部分,造成难以追溯的矛盾。一个比较正确的方法是:
contract GoodFailEarly { mapping(string => uint) nameToSalary; function getSalary(string name) constant returns (uint) { if (bytes(name).length == 0) throw; if (nameToSalary[name] == 0) throw; return nameToSalary[name]; } }
这个版本还展示了另一个理想的编程模式,分离了先决条件,使每一次失败都是孤立的。请注意,其中的一些检查(尤其是那些依赖于内部状态的)可以通过函数调节器来实现。
支持pull支付超过push支付
每一次以太币转移就意味着潜在的代码执行。接收地址可以实施一个回滚(fallback)函数,该函数可能会抛出一个错误。因此,我们永远不要相信一个发送调用的执行是没有错误的。解决方案:我们的合约应该支持pull支付超过push支付。看一下以下这个看起来无辜的竞价函数代码:
// UNSAFE CODE, DO NOT USE!
contract BadPushPayments { address highestBidder; uint highestBid; function bid() { if (msg.value < highestBid) throw; if (highestBidder != 0) { // return bid to previous winner if (!highestBidder.send(highestBid)) { throw; } } highestBidder = msg.sender; highestBid = msg.value; } }
请注意,合约调用发送函数并检查其返回值,该值看起来很合理。但它在一个函数中间调用发送,这是不安全的。为什么?记住,如上所述,发送可以触发另一个合约中的代码的执行。
想象一下,有人从一个地址出价,每次有人向该地址发送资金,就抛出一个错误。如果有人尝试出价高于这个呢?发送调用将永远失败,使竞价出现异常。一个以错误结束的函数调用会使状态不会改变(任何更改都滚回)。这意味着没有人可以出价,合约也就失败了。
最简单的解决方案是将支付分开到一个不同的函数中,让用户请求(pull)资金独立于合约逻辑的其余部分:
contract GoodPullPayments { address highestBidder; uint highestBid; mapping(address => uint) refunds; function bid() external { if (msg.value < highestBid) throw; if (highestBidder != 0) { refunds[highestBidder] += highestBid; } highestBidder = msg.sender; highestBid = msg.value; } function withdrawBid() external { uint refund = refunds[msg.sender]; refunds[msg.sender] = 0; if (!msg.sender.send(refund)) { refunds[msg.sender] = refund; } } }
这一次,我们使用一个映射来为每一位出高价的投标人储存退款值(refund),提供一个函数来提取他们的资金。在send调用出现问题的情况下,只有投标人受到影响。这是一个简单的模式,解决了许多其他问题(例如重入)。所以记住:当发送以太币时,支持pull支付超过push支付。
我已经实施了一个合约,你可以拿来用,以轻松地使用这种模式。这里有个例子展示了如何使用。
整理你的函数代码:条件,行动,相互作用
作为快速失败原则的延伸,一个很好的做法是按以下方式安排你的函数:首先,检查所有预先的条件;然后,改变你的合约的状态;最后,与其他合约进行交互。
条件,行动,相互作用。坚持这种函数结构将让你避免很多的问题。让我们来看看使用这种模式的函数的一个例子:
function auctionEnd() { // 1. Conditions if (now <= auctionStart + biddingTime) throw; // auction did not yet end if (ended) throw; // this function has already been called // 2. Effects ended = true; AuctionEnded(highestBidder, highestBid); // 3. Interaction if (!beneficiary.send(highestBid)) throw; } }
这是符合快速失败原则,由于条件在开始时就进行检查。它也将潜在危险的相互作用与其他合约一起到最后。
了解平台的限制
以太坊虚拟机(EVM)对于我们的合约能够做的事情存在很多硬性限制。这些都是平台级的安全考虑,但如果你不了解它们,就可能会威胁到你的特定合约的安全。让我们看看下面看似正确的员工奖金管理代码:
// UNSAFE CODE, DO NOT USE!
contract BadArrayUse { address[] employees; function payBonus() { for (var i = 0; i < employees.length; i++) { address employee = employees[i]; uint bonus = calculateBonus(employee); employee.send(bonus); } } function calculateBonus(address employee) returns (uint) { // some expensive computation ... } }
阅读代码:它是非常直接的,似乎是正确的。不过,它隐藏了3个基于平台限制的潜在问题。
第一个问题是,i的类型将会是uint8,因为这是保持值0所需的最小类型。如果数组有超过255个元素,函数循环不会终止,直到gas耗尽。更好的使用显式类型unit没有惊喜和更高的极限。如果可能的话,避免声明使用var的变量。让我们解决这个:
// STILL UNSAFE CODE, DO NOT USE!
contract BadArrayUse { address[] employees; function payBonus() { for (uint i = 0; i < employees.length; i++) { address employee = employees[i]; uint bonus = calculateBonus(employee); employee.send(bonus); } } function calculateBonus(address employee) returns (uint) { // some expensive computation ... } }
第二件你应该考虑的事是gas限制。gas是以太坊的机制用于收取网络资源费用。每一次修改状态的函数调用都会消耗gas。想象一下,calculateBonus在一些复杂计算的基础上计算每一位员工的奖金,就像计算许多项目的利润。这将消耗大量的gas,这很容易达到交易或区块的gas限制。如果一个交易达到了gas限制,所有的变化将被恢复,但费用仍然支付。当使用循环时,要注意可变的gas成本。让我们通过将奖金计算从for循环中分离来优化合约。请注意,随着员工数组的增长,gas成本的增长,这仍然有问题。
// UNSAFE CODE, DO NOT USE!
contract BadArrayUse { address[] employees; mapping(address => uint) bonuses; function payBonus() { for (uint i = 0; i < employees.length; i++) { address employee = employees[i]; uint bonus = bonuses[employee]; employee.send(bonus); } } function calculateBonus(address employee) returns (uint) { uint bonus = 0; // some expensive computation modifying the bonus... bonuses[employee] = bonus; } }
最后,调用堆栈深度限制。EVM调用堆栈有一个1024的硬性限制。这意味着,如果嵌套调用的数量达到1024,合约将会失败。攻击者可以递归调用一个合约1023次,然后,调用我们合约的函数,造成sends因为这个限制而默默失败。 PullPaymentCapable.sol在上面被描述过了,并允许轻松实现pull支付。从PullPaymentCapable继承,使用asyncSend保护你远离这个。
这里是代码的一个修改后的版本,解决所有这些问题:
import './PullPaymentCapable.sol';
contract GoodArrayUse is PullPaymentCapable { address[] employees; mapping(address => uint) bonuses; function payBonus() { for (uint i = 0; i < employees.length; i++) { address employee = employees[i]; uint bonus = bonuses[employee]; asyncSend(employee, bonus); } }
function calculateBonus(address employee) returns (uint) { uint bonus = 0; // some expensive computation... bonuses[employee] = bonus; } }
总结一下,一定要记得(1)你使用的类型的限制,(2)你合约gas成本的限制,和(3)调用堆栈深度限制。
编写测试
编写测试是一项很大的工作,不过可以在回归问题上拯救你。当先前正确的组件因为最近的更改而被损坏时,回归错误就会出现。
我很快会写一个关于测试的更广泛指导,但如果你好奇,你可以查看Truffle的测试指导。
容错和自动错误赏金
感谢Peter Borah对这两种思想的启发。代码审查和安全审计是不够安全的。我们的代码需要为最糟糕的情况做准备。在我们的智能合同中有一个漏洞,应该有一种方法可以让它安全地恢复。不仅如此,但我们应该尽量尽早发现这些漏洞。而我们合约纳入自动错误赏金可以提供帮助。
让我们来看一看这个简单的自动错误赏金在一个假设Token合约中的实现:
import './PullPaymentCapable.sol'; import './Token.sol';
contract Bounty is PullPaymentCapable { bool public claimed; mapping(address => address) public researchers; function() { if (claimed) throw; } function createTarget() returns(Token) { Token target = new Token(0); researchers[target] = msg.sender; return target; } function claim(Token target) { address researcher = researchers[target]; if (researcher == 0) throw; // check Token contract invariants if (target.totalSupply() == target.balance) { throw; } asyncSend(researcher, this.balance); claimed = true; } }
和以前一样,我们使用PullPaymentCapable来保护我们的付款安全。这个Bounty合约允许研究人员创建我们要审计的Token合约的副本。任何人都可以通过发送交易到Bounty合约地址为错误赏金做贡献。如果任何研究人员设法破坏了他的Token合约的副本(例如在这种情况下,使代币的总供应量不同于Token余额),他会得到赏金奖励。一旦赏金被要求了,合约将不会再接受更多资金(那无名的函数称为合约的回滚函数,每次合约被直接发送资金,该函数就会执行)。
正如你所看到的,这是一个很好的属性,它是一个单独的合同,不需要修改我们的原始的Token合约。这里是GitHub上任何人都可用的一个完整实施。
至于容错,我们将需要修改我们的原始合约,添加额外的安全机制。一个简单的想法是让一个合同的管理者冻结合约作为一种应急机制。让我们看到一种通过继承来实现这种行为的方法:
contract Stoppable { address public curator; bool public stopped;
modifier stopInEmergency { if (!stopped) _ } modifier onlyInEmergency { if (stopped) _ } function Stoppable(address _curator) { if (_curator == 0) throw; curator = _curator; } function emergencyStop() external { if (msg.sender != curator) throw; stopped = true; } }
Stoppable允许指定一个可以停止合约的管理者地址。“停止合约”是什么意思?这由从Stoppabl继承的子合约通过使用函数修改器stopInEmergency和onlyInEmergency来定义。
让我们看看一个例子:
import './PullPaymentCapable.sol'; import './Stoppable.sol';
contract StoppableBid is Stoppable, PullPaymentCapable { address public highestBidder; uint public highestBid; function StoppableBid(address _curator) Stoppable(_curator) PullPaymentCapable() {} function bid() external stopInEmergency { if (msg.value <= highestBid) throw; if (highestBidder != 0) { asyncSend(highestBidder, highestBid); } highestBidder = msg.sender; highestBid = msg.value; } function withdraw() onlyInEmergency { suicide(curator); } }
在这个例子中,投标(bid)现在可以由一个管理者(curator)来停止,这一点在合约被创建的时候就被定义好了。StoppableBid处于正常模式,只有bid函数可以被调用。如果发生奇怪的事情并且合约处于不一致的状态,管理者可以介入并激活紧急状态。这就使bid函数无法被调用,允许withdraw函数运行。
在这种情况下,应急模式只会让管理者破坏合约并找回资金。但在真实的情况下,找回逻辑可能更加复杂(例如将资金返还给它们的所有者)。这里是GitHub上任何人都可用的一个实施。
限制资金存放金额
保护我们的智能合同免受攻击的另一种方法是限制它们的范围。攻击者最有可能会将管理数百万美元的合约当作目标。并不是所有的智能合约都需要如此高的风险。特别是如果我们正在进行实验。在这种情况下,限制我们的合约接受的资金数额可能是有用的。这很简单,只要对合约地址余额添加一个硬性限制即可。
这里有一个关于如何做到这一点的简单的例子:
contract LimitFunds { uint LIMIT = 5000; function() { throw; } function deposit() { if (this.balance > LIMIT) throw; ... } }
这个简短的回滚函数将拒绝向合约的任何直接付款。如果合约的余额超过所需的限制或者出现异常,将会首先检查deposit函数。更多有趣的东西,如动态或管理的限制也很容易实现。
编写简单模块代码
安全来自于我们的意图和我们的代码实际上允许做什么之间的匹配。这是非常难以验证的,尤其是如果代码是巨大的和混乱的。这就是为什么编写简单模块代码是很重要的。
这意味着,函数应该尽可能短,代码缺点应减少到最小,文件应尽可能小,将独立逻辑分割为模块,每个模块都承担一些单一的责任。
命名也是在编码时表达我们意图的最好方式之一。认真思考你选择的名字,使你的代码尽可能清晰。
让我们研究一个不良命名的例子。看看来自The DAO的一个函数。我不打算在这里复制函数代码,因为它很长。
最大的问题是它太长并且复杂。尝试让你的函数更短,最多30到40行代码。理想情况下,您应该能够在不到一分钟内读取函数并了解它们所能做的。另一个问题是对在第685行的Transfer事件的不良命名。这个名字与一个叫做transfer的函数只有一个字符的不同。这会让每一个人感到困惑。在一般情况下,建议的事件命名是它们应该从‘Log’开始。在这种情况下,LogTransfer这个名字会更好。
记住,尽可能地让你的智能合约编写简单,模块化和命名良好。这将大大有利于别人和你自己审核你的代码。
不要从零开始编写你所有的代码
最后,正如老话所说:“不要转移你的密码”。我认为它也适用于智能合约代码。你在与钱打交道,你的代码和数据是公开的,你运行在一个新的实验平台上,风险很高。到处都可能会造成混乱。
这些做法有助于保护我们的智能合约。但最终,我们应该创建更好的开发工具来建立智能合约。这里有一些有趣的举措,包括更好类型系统, Serenity Abstractions,和Rootstock平台。
有很多已经编写的很好的和安全的代码,框架开始出现。我们在这个我们称为OpenZeppelin的GitHub repo已经开始编译一些最佳实践。随意地看看,并贡献新的代码或安全审计。
总结
综上所述,本文所描述的安全模式:
- 代码失败要尽快,并且大声谈论失败
- 支持pull支付超过push支付
- 整理你的函数代码:条件,行动,相互作用
- 了解平台的限制
- 编写测试
- 容错和自动错误赏金
- 限制资金存放金额
- 编写简单模块代码
- 不要从零开始编写你所有的代码
如果你想加入关于安全智能合约开发模式的讨论,在slack上加入我们。让我们共同提高智能合约的发展水平!
了解我们最新的智能合约安全工作,请关注我们的Medium和Twitter。