安全考虑

翻译原文

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来发送以太币,下面几个要点要特别注意:
  1. 如果接收者是一个合约,这会引发回调函数的执行,并且能够,返过来回调调用者的函数。
  2. 如果栈深度超过1024,发送以太币的操作会失败。因为调用者完全控制了调用深度,他可以强制关闭交易。这个是合约的能力,或者使用send并且保证总是检查他的返回值。更好的是,合约按照一定的模式来写,使得接收者可以取回以太币。
  3. 发送以太币可能会失败,因为执行接收者合约需要更多的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),你可以使用参数0xff0000010x00000001来欺骗交易。这两个参数传进函数的时候,x都会觉得是1,但是msg.data就会不同。如果任何地方使用keccak256(msg.data),就会有不同的结果。

建议

限制发送的以太币数目

限制保存在智能合约里的以太币(或者其他代币)的数量。如果你的源码,或者平台或者编译器出现问题,这些钱币可能会丢失。如果你想要限制你的损失,那就限制钱币数目。

保证它小型和模块化

让你的合约保持小巧和容易理解。把不相关的函数移到其他合约或者库中。针对提高源码质量的通常的建议是:限制局部变量的数量,函数的长度等等。给你的函数提供文档,那么其他人可以知道你的意图,并且知道代码是否是这么做了。

使用检测交互效果模式

很多函数会首先检查代码(调用函数方的参数是否符合要求,是否发送了足量的以太币,用户是否有令牌等)。这些检查应该先执行。

第二步,如果所有的检测通过了,当前合约就会修改这些状态。和其他合约的交互在任何函数都应该放在最后一步。

早期合约发布一些功能并等待外部函数调用来返回非错误状态。这通常是一个严重的错误,因为上面所论述的重入问题。

注意,对已知合约的调用,也可能引起未知合约的调用。所以只使用这个模式可能会更好。

包含一个失败安全模式

当你的系统完全去中心化的时候,会移除所有的中间人。引入失败安全机制可能是一个好的想法,尤其是对新的代码:

你可以在智能合约中添加一个函数,用来做“是否有以太币泄露”的自检,“是否所有花费跟合约的余额一致”或者类似的事情。需要注意的是,你不能为这个检查花费太多的gas。所有这里可能需要off-chain计算。

如果自我检查失败了,合约会自动转换到安全模式,例如,关闭大部分功能,移交控制权来修复了bug的,并且信任的第三方或者只是把合约转换为简单的“把钱还给我”合约。

正规化校验

使用正规化校验,他可能实现自动数学验证你的源码包含一种特定的正规要求。要求也是正规化的(就像源码一样),但是通常会更加简单。

需要注意的是正规化校验本身只能帮助你理解“你做了什么”(要求)和“你怎么做”(你的实现)之间的区别。你需要检查要求是不是你所期望的并且没有漏过任何不期望的效果。

你可能感兴趣的:(安全考虑)