翻译原文
date:20170728
按照以往的经验来开发软件通常会比较简单,但是如果用前无古人的方法来写就会有些难度。
在Solidity中,这尤其重要,因为你可以用智能合约来处理token或者更有甚者其他有价值的东西。另外,智能合约的每次执行都是公开的,而且源码通常是公开的。
当然你总是会关心它的花费:你可以把智能合约跟网站服务作比较,他们都是面向公众的(因此,也会有恶意的黑客)并且可能是开源的。如果你只想把你的购物清单保存在网站服务中,你不必关系很多,如果你用网站服务来管理你的银行账户,你就要多加小心了。
这个章节会罗列出一些陷阱和常见安全建议,当然,永远不会补充完整。所以,你也应该心里有数,尽管你的智能合约不会出现问题,但是编译器或者平台本身可能会出现问题。编译器的安全相关问题都罗列在已知bug列表中,也是机器可读的。注意,针对Solidity 编译器的代码生成器,有一个bug赏金程序。
也请你帮组我们来完善这个开源文档(尤其是,多写几个例子)。
陷阱
私有信息和随机性
你使用智能合约的所有事情,都是公共可见的,即使是局部变量和标记为private
的状态变量。
如果你不想矿工作弊,在智能合约中使用随机数是很好的技巧。
重入(RE-Entrancy)
任何合约A与合约B的交互以及任何发送以太币都会把程序控制权交给合约B。这使得合约B在交互结束之前,可以回调A。举个例子,下面的代码有bug(这只是代码片段,不是完整的合约代码):
pragma solidity ^0.4.0;
// 这个合约包含bug,请不要使用
contract Fund {
/// 映射合约的以太币股份
mapping(address => uint) shares;
/// 取回你的股份
function withdraw() {
if (msg.sender.send(shares[msg.sender]))
shares[msg.sender] = 0;
}
}
这个问题不是很严重,因为send
也有gas限制,但是依旧暴露出一个问题:以太币交易总是伴随着代码执行,所以接收者可能是一个会执行withdraw
函数的合约。这会使得它可以多次取回金额,并且可以取回该合约的所有以太币。
为了防止重入,你可以使用下面的检查影响交互模式:
pragma solidity ^0.4.11;
contract Fund {
/// 映射合约的以太币股份
mapping(address => uint) shares;
/// 取回股份
function withdraw() {
var share = shares[msg.sender];
shares[msg.sender] = 0;
msg.sender.transfer(share);
}
}
注意,重入不仅对以太币交易有影响,而且可以是合约里的任何函数。另外,你必须考虑合约账户的多合约交互情况。一个被调用的合约可能会改变调用者的状态。
gas限制和循环
循环不会有固定次数的遍历,例如,循环依赖storage变量,一定要小心:因为gas的限制,交易只能消耗特定数量的gas。要么指定,要么只是执行正常的操作,循环的遍历次数必须在gas限制以内,gas不足会导致整个合约在某个时刻熄火。这不支持只是从区块链中读取数据的constant
函数。这些函数仍然可能被其他合约调用作为on-chain操作的一部分,并且失败。请在你的合约中指出这种情况。
发送和接收以太币
- 目前,合约和外部账户,都不能阻止某人给他们发送以太币。合约可以交互和拒绝常规的交易,但是可以有很多方式来移动以太币,而无需消息调用。一种方法是简单的在某个合约地址上挖矿,第二种方法是使用
selfdesruct(x)
。 - 如果一个合约接收以太币(没有调用函数),回调函数就会执行。如果没有回调函数,以太币就会被拒绝(通过抛出异常)。在执行回调函数过程中,合约只依赖于当时所需的“gas薪金”(2300gas)。但是该薪金不足以访问storage。为了保证你的合约可以接受以太币,要检查回调函数所需的gas数目(例子在Remix的详情章节)。
- 接收合约使用
addr.call.value(x)()
会传递更多的gas。这个函数只有在传递所有剩余的gs和对接受者开放其他更昂贵的操作(并且它在操作失败的情况下只返回失败代码,并不会自动传递错误)的情况下,和addr.transfer(x)
的表现一致。这可能包含对调用方的回调或者其他你不期望的状态改变。所以这为诚实的或者恶意的用户提供了很高的灵活性。 - 如果使用
address.transfer
来发送以太币,下面几个要点要特别注意:
- 如果接收者是一个合约,这会引发回调函数的执行,并且能够,返过来回调调用者的函数。
- 如果栈深度超过1024,发送以太币的操作会失败。因为调用者完全控制了调用深度,他可以强制关闭交易。这个是合约的能力,或者使用
send
并且保证总是检查他的返回值。更好的是,合约按照一定的模式来写,使得接收者可以取回以太币。 - 发送以太币可能会失败,因为执行接收者合约需要更多的gas(使用
require
,assert
,revert
,throw
或者因为操作本来就很昂贵)-它会返回“gas不足”(OOG)。如果你使用transfer
或者send
之后有对返回值进行检查,这会给接收者提供中断交易的方法。再说一次,用取回模式替代发送模式是很好的练习。
回调深度
外部函数调用可能在任何时候都会失败,应为他们的最大调用栈深为1024.这种情况下,Solidity会抛出一个异常。恶意的黑客可能会在调用你的合约之前把栈深提高。
注意,send()
函数在调用栈被耗尽的情况下,不会抛出异常,而是返回false
。底层函数.call()
,.callcode()
和.delegatecall()
的行为都是一样的。
tx.orgin
永远不要使用tx.orgin来验证。我们假定你有如下的钱包合约:
pragma solidity ^0.4.11;
// 这个合约包含bug - 不要使用
contract TxUserWallet {
address owner;
function TxUserWallet() {
owner = msg.sender;
}
function transferTo(address dest, uint amount) {
require(tx.origin == owner);
dest.transfer(amount);
}
}
现在有人欺骗你,让你把以太币发送到这个攻击钱包的地址上:
pragma solidity ^0.4.11;
interface TxUserWallet {
function transferTo(address dest, uint amount);
}
contract TxAttackWallet {
address owner;
function TxAttackWallet() {
owner = msg.sender;
}
function() {
TxUserWallet(msg.sender).transferTo(owner, msg.sender.balance);
}
}
如果你的钱包对msg.sender
进行验证,它会获取到攻击的钱包地址,而不是所有者的地址。通过验证tx.orgin
,他会得到初始地址,攻击者会获取到你的所有金额。
次要详情
- 在
for (var i = 0; i < arrayName.length; i++) { ... }
中,i
的类型为uint8
。因为它是用来保存0
的最小的类型。如果数组有大于255个元素,循环会被终止。 - 目前,对有
constant
修饰的函数编译器不会强制要求不可变。另外EVM也没有强制。所以虽然合约中声明为静态,但是依旧会被改变。 - 不占据完整32位字节的类型会包含“高位脏数据”。如果你访问
msg.data
,这一点尤其重要-它暴露了一个可扩展性问题:针对函数f(uint8 x)
,你可以使用参数0xff000001
和0x00000001
来欺骗交易。这两个参数传进函数的时候,x都会觉得是1
,但是msg.data
就会不同。如果任何地方使用keccak256(msg.data)
,就会有不同的结果。
建议
限制发送的以太币数目
限制保存在智能合约里的以太币(或者其他代币)的数量。如果你的源码,或者平台或者编译器出现问题,这些钱币可能会丢失。如果你想要限制你的损失,那就限制钱币数目。
保证它小型和模块化
让你的合约保持小巧和容易理解。把不相关的函数移到其他合约或者库中。针对提高源码质量的通常的建议是:限制局部变量的数量,函数的长度等等。给你的函数提供文档,那么其他人可以知道你的意图,并且知道代码是否是这么做了。
使用检测交互效果模式
很多函数会首先检查代码(调用函数方的参数是否符合要求,是否发送了足量的以太币,用户是否有令牌等)。这些检查应该先执行。
第二步,如果所有的检测通过了,当前合约就会修改这些状态。和其他合约的交互在任何函数都应该放在最后一步。
早期合约发布一些功能并等待外部函数调用来返回非错误状态。这通常是一个严重的错误,因为上面所论述的重入问题。
注意,对已知合约的调用,也可能引起未知合约的调用。所以只使用这个模式可能会更好。
包含一个失败安全模式
当你的系统完全去中心化的时候,会移除所有的中间人。引入失败安全机制可能是一个好的想法,尤其是对新的代码:
你可以在智能合约中添加一个函数,用来做“是否有以太币泄露”的自检,“是否所有花费跟合约的余额一致”或者类似的事情。需要注意的是,你不能为这个检查花费太多的gas。所有这里可能需要off-chain计算。
如果自我检查失败了,合约会自动转换到安全模式,例如,关闭大部分功能,移交控制权来修复了bug的,并且信任的第三方或者只是把合约转换为简单的“把钱还给我”合约。
正规化校验
使用正规化校验,他可能实现自动数学验证你的源码包含一种特定的正规要求。要求也是正规化的(就像源码一样),但是通常会更加简单。
需要注意的是正规化校验本身只能帮助你理解“你做了什么”(要求)和“你怎么做”(你的实现)之间的区别。你需要检查要求是不是你所期望的并且没有漏过任何不期望的效果。