安全注意事项
虽然构建按预期工作的软件通常很容易,但更难检查是否有人可以用**未**预料的方式使用它。
在 Solidity 中,这一点更加重要,因为您可以使用智能合约来处理代币,或者可能处理更有价值的东西。此外,智能合约的每次执行都是公开的,而且,除了这一点之外,源代码通常也是可用的。
当然,您始终必须考虑风险有多大:您可以将智能合约与对公众(因此,也对恶意行为者)开放的 Web 服务进行比较,并且可能甚至是对开源的。如果您只是在该 Web 服务上存储您的购物清单,您可能不必太小心,但是如果您使用该 Web 服务管理您的银行账户,您应该更加小心。
本节将列出一些陷阱和一般的安全建议,但当然永远不可能完整。此外,请记住,即使您的智能合约代码没有错误,编译器或平台本身也可能存在错误。可以在已知错误列表中找到一些公开的与安全相关的编译器错误,该列表也是机器可读的。请注意,有一个漏洞赏金计划涵盖 Solidity 编译器的代码生成器。
与开源文档一样,请帮助我们扩展本节(特别是,一些示例不会有坏处)!
注意:除了下面的列表之外,您可以在Guy Lando 的知识列表和Consensys GitHub 仓库中找到更多安全建议和最佳实践。
陷阱
私密信息和随机数
您在智能合约中使用的所有内容都是公开可见的,即使是标记为 private
的局部变量和状态变量。
如果您不希望区块构建者能够作弊,那么在智能合约中使用随机数非常棘手。
重入
合约 (A) 与另一个合约 (B) 的任何交互以及任何以太坊的转移都会将控制权交到该合约 (B) 手中。这使得 B 可以回叫 A,然后再完成此交互。举个例子,以下代码包含一个错误(它只是一个片段,而不是一个完整的合约)
// 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
中的 gas 有限,但它仍然暴露了一个弱点:以太坊转移始终可以包含代码执行,因此接收者可能是一个回叫 withdraw
的合约。这将使它能够获得多次退款,并且基本上可以检索合约中的所有以太坊。特别是,以下合约将允许攻击者多次退款,因为它使用 call
,该函数默认情况下会转发所有剩余的 gas
// 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);
}
}
检查-效果-交互模式确保所有通过合约的代码路径都将在修改合约状态之前完成对提供的参数的所有必需检查(检查);只有在那之后,它才会对状态进行任何更改(效果);它可能会在所有计划的状态更改都写入存储之后调用其他合约中的函数(交互)。这是一种防止重入攻击的常见防范措施,在重入攻击中,外部调用的恶意合约可以通过使用在最终化其事务之前回叫原始合约的逻辑来重复花费津贴、重复提取余额等。
请注意,重入不仅是以太坊转移的结果,也是对另一个合约的任何函数调用的结果。此外,您还必须考虑多合约情况。被调用的合约可能会修改您所依赖的另一个合约的状态。
Gas 限制和循环
迭代次数不固定的循环(例如,依赖于存储值的循环)必须谨慎使用:由于区块 gas 限制,事务只能消耗一定量的 gas。无论是显式还是由于正常操作,循环中的迭代次数都可能超过区块 gas 限制,这会导致整个合约在某个点停滞。这可能不适用于仅用于从区块链读取数据的 view
函数。尽管如此,此类函数可能被其他合约作为链上操作的一部分调用,并使其停滞。请在合约的文档中明确说明此类情况。
发送和接收以太坊
目前,无论是合约还是“外部账户”都无法阻止任何人向其发送以太坊。合约可以对常规转移做出反应并拒绝它,但有一些方法可以移动以太坊而不创建消息调用。一种方法是简单地“挖矿”到合约地址,第二种方法是使用
selfdestruct(x)
。如果合约收到以太坊(没有调用函数),则执行接收以太坊或回退函数。如果没有
receive
也不没有fallback
函数,则以太坊将被拒绝(通过抛出异常)。在执行这些函数中的一个函数期间,合约只能依赖于它传递的“gas 津贴”(2300 gas)在那个时候对其可用。这种津贴不足以修改存储(但不要认为这是理所当然的,津贴可能会随着未来的硬分叉而改变)。要确保您的合约能够以这种方式接收以太坊,请检查接收和回退函数的 gas 需求(例如,在 Remix 的“详细信息”部分中)。有一种方法可以使用
addr.call{value: x}("")
向接收合约转发更多 gas。这基本上与addr.transfer(x)
相同,只是它会转发所有剩余的 gas,并且可以让接收者执行更多昂贵的操作(并且它会返回一个失败代码,而不是自动传播错误)。这可能包括回叫发送合约或您可能没有想到的其他状态更改。因此,它允许诚实的用户拥有很大的灵活性,但也允许恶意行为者拥有很大的灵活性。使用最精确的单位来表示 Wei 数量,因为任何因缺乏精度而四舍五入的 Wei 都将丢失。
如果您想使用
address.transfer
发送以太坊,则需要了解某些细节如果接收者是一个合约,则会导致其接收或回退函数被执行,这反过来又会回叫发送合约。
发送以太坊可能会由于调用深度超过 1024 而失败。由于调用者完全控制调用深度,因此他们可以强制转移失败;请考虑这种可能性,或者使用
send
并确保始终检查其返回值。更好的是,使用接收者可以提取以太坊的模式编写您的合约。发送以太坊也可能失败,因为接收者合约的执行需要比分配的 gas 量更多(通过显式使用 require、assert、revert 或者因为操作太昂贵) - 它“耗尽了 gas”(OOG)。如果您使用
transfer
或send
进行返回值检查,这可能会为接收者提供一种在发送合约中阻止进程的方式。同样,这里最佳实践是使用“提取”模式而不是“发送”模式。
调用栈深度
外部函数调用可能在任何时候失败,因为它们超过了 1024 的最大调用栈大小限制。在这种情况下,Solidity 会抛出一个异常。恶意行为者可能会在与您的合约交互之前将调用栈强制到一个较高的值。请注意,自从 Tangerine Whistle 硬分叉后,63/64 规则 使调用栈深度攻击变得不切实际。还要注意,调用栈和表达式栈是无关的,即使它们都具有 1024 个堆栈槽的大小限制。
请注意 .send()
在调用栈耗尽时不会抛出异常,而是返回 false
。低级函数 .call()
、.delegatecall()
和 .staticcall()
的行为方式相同。
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;
Solidity 有两种模式来处理这些上溢:检查模式和未检查模式或“包装”模式。
默认的检查模式将检测上溢并导致断言失败。您可以使用 unchecked { ... }
禁用此检查,从而使上溢被静默忽略。如果包装在 unchecked { ... }
中,上面的代码将返回 0
。
即使在检查模式下,也不要假设您免受上溢错误的影响。在这种模式下,上溢将始终回滚。如果无法避免上溢,这会导致智能合约陷入特定状态。
一般来说,了解二进制补码表示的局限性,它甚至对带符号数字有一些更特殊的边缘情况。
尝试使用 require
将输入的大小限制在合理的范围内,并使用 SMT 检查器 来查找潜在的上溢。
清除映射
Solidity 类型 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
也会返回 256。
如果必须删除 mapping
信息,请考虑使用类似于 可迭代映射 的库,这使您可以遍历键并在适当的 mapping
中删除它们的值。
次要细节
不占用完整 32 字节的类型可能包含“脏的高位”。这在您访问
msg.data
时尤其重要 - 它构成了可塑性风险:您可以创建调用函数f(uint8 x)
的交易,其原始字节参数为0xff000001
和0x00000001
。这两个都会被馈送到合约,并且在x
方面,它们看起来都像数字1
,但msg.data
将不同,因此,如果您对任何内容使用keccak256(msg.data)
,您将获得不同的结果。
建议
认真对待警告
如果编译器向您发出有关某事的警告,您应该进行更改。即使您认为此特定警告没有安全隐患,也可能存在隐藏在它之下的其他问题。我们发出的任何编译器警告都可以通过对代码进行轻微更改来消除。
始终使用最新版本的编译器,以了解所有最近引入的警告。
由编译器发出的类型为 info
的消息并不危险,只是表示编译器认为可能对用户有用的额外建议和可选信息。
限制以太币数量
限制可以存储在智能合约中的以太币(或其他代币)数量。如果您的源代码、编译器或平台存在错误,这些资金可能会丢失。如果您想限制损失,请限制以太币的数量。
保持小巧和模块化
使您的合约保持小巧,易于理解。将不相关的功能分离到其他合约或库中。当然,关于源代码质量的一般建议也适用:限制局部变量的数量、函数的长度等。记录您的函数,以便其他人可以了解您的意图,以及它是否与代码执行的操作不同。
使用检查-效果-交互模式
大多数函数将首先执行一些检查,并且它们应该首先进行(谁调用了该函数,参数是否在范围内,他们是否发送了足够的以太币,该人是否有代币等)。
作为第二步,如果所有检查都通过,则应对当前合约的状态变量进行更改。与其他合约的交互应该是任何函数中的最后一步。
早期的合约推迟了一些效果,并在等待外部函数调用返回非错误状态。由于上面解释的重入问题,这通常是一个严重的错误。
请注意,对已知合约的调用也可能反过来导致对未知合约的调用,因此最好始终应用此模式。
包括失效保护模式
虽然使您的系统完全去中心化将消除任何中介机构,但对于新代码来说,包括某种失效保护机制可能是一个好主意。
您可以在智能合约中添加一个函数,该函数执行一些自我检查,例如“是否有以太币泄漏?”、“代币的总和是否等于合约的余额?”或类似的事情。请记住,您不能为此使用过多的 gas,因此可能需要通过链下计算来提供帮助。
如果自我检查失败,合约会自动切换到某种“失效保护”模式,例如,禁用大多数功能,将控制权交给固定的可信第三方,或者只是将合约转换为简单的“返还我的以太币”合约。
寻求同行评审
审查代码的人越多,发现的问题就越多。请人们审查您的代码也有助于作为交叉检查,以确定您的代码是否易于理解 - 这是良好智能合约的重要标准。