安全注意事项

虽然通常很容易构建如预期般工作的软件,但是要检查是否没有人能够以一种 not 预期的。

在Solidity中,这一点更为重要,因为您可以使用智能合约来处理代币,甚至可能是更值钱的东西。此外,智能合约的每一次执行都是公开的,除此之外,源代码通常是可用的。

当然,你总是要考虑有多大的风险:你可以将一个智能合约与一个对公众开放(因此,也对恶意参与者开放)甚至是开源的Web服务进行比较。如果您只将您的杂货清单存储在该Web服务上,那么您可能不必太在意,但是如果您使用该Web服务管理您的银行帐户,那么您应该更加小心。

本节将列出一些陷阱和一般的安全建议,但当然,永远不会完整。另外,请记住,即使智能合约代码没有bug,编译器或平台本身也可能有bug。编译器的一些已知的安全相关错误的列表可以在 list of known bugs ,这也是机器可读的。注意,有一个bug boundy程序,它包含solidity编译器的代码生成器。

和往常一样,使用开源文档,请帮助我们扩展此部分(尤其是一些示例不会造成伤害)!

注意:除了下面的列表,您还可以找到更多的安全建议和最佳实践 in Guy Lando's knowledge listthe Consensys GitHub repo .

陷阱

私人信息和随机性

智能合约中使用的所有内容都是公开可见的,即使是标记了局部变量和状态变量 private .

如果你不想让矿工作弊,那么在智能合约中使用随机数就相当困难。

重新进入

合同(a)与另一合同(b)之间的任何相互作用,以及将以太坊移交控制权转让给该合同(b)。这使得B可以在交互完成之前回调到A中。举个例子,下面的代码包含一个bug(它只是一个片段,而不是一个完整的契约):

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;

// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
    /// @dev Mapping of ether shares of the contract.
    mapping(address => uint) shares;
    /// Withdraw your share.
    function withdraw() public {
        if (payable(msg.sender).send(shares[msg.sender]))
            shares[msg.sender] = 0;
    }
}

这里的问题不太严重,因为作为 send 但它仍然暴露出一个弱点:以太网传输始终可以包括代码执行,因此接收者可以是一个调用 withdraw .这将使它获得多次退款,并基本上收回合同中的所有 Ether 。特别是,以下契约将允许攻击者在使用时多次退款 call 默认情况下,会转发所有剩余气体:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.2 <0.9.0;

// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
    /// @dev Mapping of ether shares of the contract.
    mapping(address => uint) shares;
    /// Withdraw your share.
    function withdraw() public {
        (bool success,) = msg.sender.call{value: shares[msg.sender]}("");
        if (success)
            shares[msg.sender] = 0;
    }
}

为了避免再次进入,您可以使用下面进一步概述的检查效果交互模式:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;

contract Fund {
    /// @dev Mapping of ether shares of the contract.
    mapping(address => uint) shares;
    /// Withdraw your share.
    function withdraw() public {
        uint share = shares[msg.sender];
        shares[msg.sender] = 0;
        payable(msg.sender).transfer(share);
    }
}

请注意,可重入性不仅是以太网传输的影响,而且是对另一个合同的任何功能调用的影响。此外,您还必须考虑多个合同情况。被调用的合同可以修改您所依赖的另一个合同的状态。

气体极限和回路

没有固定迭代次数的循环,例如依赖于存储值的循环,必须小心使用:由于块气体限制,事务只能消耗一定数量的气体。无论是明确地还是仅仅由于正常的操作,循环中的迭代次数都可能超过区块气限值,从而导致整个合同在某一点上停滞。这可能不适用于 view 仅用于从区块链读取数据的函数。但是,这些功能可以被其他契约调用,作为链上操作的一部分,并暂停这些操作。请在合同文件中明确说明此类情况。

发送和接收 Ether

  • 目前,无论是合同还是“外部账户”都无法阻止有人向他们发送以太网络。合同可以响应并拒绝定期的传输,但是有一些方法可以在不创建消息调用的情况下移动以太网。一种方法是简单地“我的到”合同地址,第二种方法是使用 selfdestruct(x) .

  • 如果协定接收到Ether(没有调用函数),则 receive Etherfallback 函数已执行。如果它没有receive或fallback函数,Ether将被拒绝(通过抛出异常)。在履行其中一项职能期间,合同只能依靠当时获得的“天然气津贴”(2300燃气)。这个津贴不足以修改存储(但不要认为这是理所当然的,津贴可能会随着未来的硬分叉而改变)。为了确保您的合同能够以这种方式接收乙醚,请检查接收和回退功能的气体需求(例如,在Remix的“详细信息”部分)。

  • 有一种方法可以使用 addr.call{{value: x}}("") .这基本上与 addr.transfer(x) 只有它转发所有剩余的气体,并为接收者打开执行更昂贵操作的能力(它返回一个故障代码,而不是自动传播错误)。这可能包括调回发送合同或您可能没有想到的其他状态更改。因此,它为诚实的用户以及恶意的参与者提供了很大的灵活性。

  • 使用最精确的单位来尽可能地表示wei值,因为您会丢失由于缺乏精确性而舍入的任何值。

  • 如果你想用 address.transfer 有一些细节需要注意:

    1. 如果接收者是一个契约,它将执行其receive或fallback函数,该函数反过来可以回调发送的契约。

    2. 由于呼叫深度超过1024,发送 Ether 可能会失败。由于呼叫者完全控制呼叫深度,因此他们可以强制传输失败;考虑到这种可能性或使用 send 并确保始终检查其返回值。更好的是,用一种模式来写你的合同,在这种模式下,接受者可以提取 Ether 。

    3. 发送乙醚也可能失败,因为执行接受方合同所需的气体量超过了分配的气体量(通过使用 requireassertrevert 或者是因为手术太贵了)它“没油了”(OOG)。如果你使用 transfersend 通过返回值检查,这可能为收件人提供阻止发送合同进度的方法。同样,这里的最佳实践是使用 "withdraw" pattern instead of a "send" pattern .

调用堆栈深度

外部函数调用随时可能失败,因为它们超过了1024的最大调用堆栈大小限制。在这种情况下,Solidity抛出一个例外。恶意行为者可能会在它们与您的约定交互之前强制调用堆栈设置为高值。请注意,由于 Tangerine Whistle 硬叉,这个 63/64 rule 使调用堆栈深度攻击不切实际。还要注意,调用堆栈和表达式堆栈是无关的,即使它们都有1024个堆栈插槽的大小限制。

注意 .send()not 如果调用堆栈已耗尽但返回,则引发异常 false 在这种情况下。低级功能 .call().delegatecall().staticcall() 以同样的方式行事。

授权代表

如果您的约定可以充当代理,即,如果它可以使用用户提供的数据调用任意约定,那么用户基本上可以承担代理约定的身份。即使您有其他保护措施,最好构建您的合同系统,使代理没有任何权限(即使是它本身也没有权限)。如果需要,您可以使用第二个代理来完成此操作:

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract ProxyWithMoreFunctionality {
    PermissionlessProxy proxy;

    function callOther(address _addr, bytes memory _payload) public
            returns (bool, bytes memory) {
        return proxy.callOther(_addr, _payload);
    }
    // Other functions and other functionality
}

// This is the full contract, it has no other functionality and
// requires no privileges to work.
contract PermissionlessProxy {
    function callOther(address _addr, bytes memory _payload) public
            returns (bool, bytes memory) {
        return _addr.call(_payload);
    }
}

tx.origin

切勿使用tx.origin进行授权。假设你有这样一个钱包合同:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract TxUserWallet {
    address owner;

    constructor() {
        owner = msg.sender;
    }

    function transferTo(address payable dest, uint amount) public {
        // THE BUG IS RIGHT HERE, you must use msg.sender instead of tx.origin
        require(tx.origin == owner);
        dest.transfer(amount);
    }
}

现在有人骗你把以太发送到这个攻击钱包的地址:

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
interface TxUserWallet {
    function transferTo(address payable dest, uint amount) external;
}

contract TxAttackWallet {
    address payable owner;

    constructor() {
        owner = payable(msg.sender);
    }

    receive() external payable {
        TxUserWallet(msg.sender).transferTo(owner, msg.sender.balance);
    }
}

如果你的钱包检查过 msg.sender 为了获得授权,它将获取攻击钱包的地址,而不是所有者地址。但是通过检查 tx.origin ,它获取启动事务的原始地址,该地址仍然是所有者地址。攻击钱包会立即耗尽你所有的资金。

二的补码/下溢/溢出

与许多编程语言一样,Solidity的整数类型实际上并不是整数。当值较小时,它们类似于整数,但不能表示任意大的数字。

下面的代码会导致溢出,因为加法的结果太大,无法存储在类型中 uint8

uint8 x = 255;
uint8 y = 1;
return x + y;

实心度有两种模式来处理这些溢出:选中和取消选中或“包装”模式。

默认选中模式将检测溢出并导致断言失败。您可以使用以下命令禁用此检查 unchecked {{ ... }} ,导致溢出被静默忽略。上述代码将返回 0 如果包裹在 unchecked {{ ... }}

即使在选中模式下,也不要认为您受到保护,不会出现溢出错误。在此模式下,溢出将始终恢复。如果无法避免溢出,这可能会导致智能合约陷入特定状态。

一般来说,阅读关于二的补码表示的极限,它甚至对有符号数有一些更特殊的边缘情况。

试着使用 require 要将输入的大小限制在合理范围内,并使用 SMT checker 以查找潜在的溢出。

清除映射

固体类型 mapping (见 映射类型 )是一个只存储键值的数据结构,它不跟踪分配了非零值的键。因此,在没有关于已写入密钥的额外信息的情况下清理映射是不可能的。如果 mapping 用作动态存储阵列的基类型,删除或弹出该阵列对 mapping 元素。例如,如果 mapping 用作的成员字段的类型 struct 这是动态存储阵列的基本类型。这个 mapping 在包含 mapping .

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.6.0 <0.9.0;

contract Map {
    mapping (uint => uint)[] array;

    function allocate(uint _newMaps) public {
        for (uint i = 0; i < _newMaps; i++)
            array.push();
    }

    function writeMap(uint _map, uint _key, uint _value) public {
        array[_map][_key] = _value;
    }

    function readMap(uint _map, uint _key) public view returns (uint) {
        return array[_map][_key];
    }

    function eraseMaps() public {
        delete array;
    }
}

考虑上面的示例和以下调用序列: allocate(10)writeMap(4, 128, 256) . 在这一点上 readMap(4, 128) 返回256。如果我们打电话 eraseMaps ,状态变量的长度 array 是零,但是因为它 mapping 元素不能归零,它们的信息在契约的存储中保持活动状态。删除后 array 呼唤 allocate(5) 允许我们访问 array[4] 又来了,打电话来 readMap(4, 128) 即使不调用另一个 writeMap .

如果你 mapping 必须删除信息,请考虑使用类似的库 iterable mapping ,允许您遍历关键点并在适当的 mapping .

次要细节

  • 不占用全部32个字节的类型可能包含“脏的高阶位”。如果您访问 msg.data -它会带来延展性风险:您可以手工创建调用函数的事务 f(uint8 x) 使用原始字节参数 0xff000001 并且有了 0x00000001 。这两个都被输入到合同中,而且看起来都像是数字 1 远至 x 是令人担忧的,但是 msg.data 会有所不同,所以如果您使用 keccak256(msg.data) 无论如何,您都会得到不同的结果。

建议

认真对待警告

如果编译器警告您某些内容,您应该对其进行更改。即使您不认为此特定警告具有安全隐患,但它背后可能隐藏着另一个问题。我们发出的任何编译器警告都可以通过对代码稍作更改来使其静默。

总是使用最新版本的编译器来通知最近引入的所有警告。

类型的消息 info 编译器发出的命令并不危险,只是表示编译器认为可能对用户有用的额外建议和可选信息。

限制 Ether 的量

限制可以存储在智能合约中的 Ether (或其他令牌)的数量。如果源代码、编译器或平台有缺陷,这些资金可能会丢失。如果你想限制你的损失,就限制 Ether 的量。

保持小型化和模块化

保持你的合同小,容易理解。在其他合同或库中找出不相关的功能。当然,关于源代码质量的一般建议也适用:限制局部变量的数量、函数的长度等等。记录您的函数,以便其他人可以看到您的意图,以及它是否与代码不同。

使用检查效果交互模式

大多数函数将首先执行一些检查(谁调用了函数,参数在范围内,他们是否发送了足够的 Ether ,人员是否有令牌等)。这些检查应该先进行。

作为第二步,如果所有检查都通过,则应对当前合同的状态变量产生影响。与其他合同的交互应该是任何功能的最后一步。

早期的契约延迟了一些效果,并等待外部函数调用以非错误状态返回。这通常是一个严重的错误,因为上面解释的重新进入问题。

请注意,对已知契约的调用可能反过来导致对未知契约的调用,因此最好始终应用此模式。

包括故障保护模式

虽然使您的系统完全分散将删除任何中介,但最好包括某种故障安全机制,尤其是对于新代码:

您可以在智能合约中添加一个执行一些自我检查的函数,例如“是否有 Ether 泄漏?”“代币的总和等于合同的余额吗?”或者类似的事情。请记住,您不能为此使用太多的气体,因此可能需要通过链外计算提供帮助。

如果自检失败,合同将自动切换到某种“故障保护”模式,例如,禁用大部分功能,将控制权移交给固定的、受信任的第三方,或者将合同转换为简单的“还钱”合同。

要求同行评审

检查一段代码的人越多,发现的问题就越多。让人们检查您的代码也有助于交叉检查您的代码是否易于理解——这是良好智能合约的一个非常重要的标准。