区块链安全-Solidity安全建议
安全事项
虽然通常很容易构建能够正常运作的软件, 但更难的是确定没有人能够以不是预期的方式使用它。
Solidity中,这更加重要,因为你会使用智能合约来 控制token,甚至可能是更加宝贵的东西。 此外,每次 智能合约的执行,都是公开的。并且, 代码也常常是开源的。
当然,你总是必须考虑这个问题有多大: 你可以用一个web服务来和智能合约进行对比,这个web服务向公众开放 (也可能是向恶意攻击者),甚至可能是开源的。 如果您只是在那个web服务上存储您的杂货列表, 您可能没有必要 关心太多,如果你使用这个web服务处理你的银行账户, 你应该更加谨慎。
本节将列出一些陷阱和一般安全建议,但
当然,永远不可能是完整的。 还有,请记住,即使你的智能
合约代码是没有bug的,编译器或平台本身可能有一个
bug。 编译器中一些已公开的安全错误列表
可以在 :ref:list of known bug<known_bugs>
中找到, 这也是
机器可读的。 注意有一个覆盖Solidity编译器的代码生成器的 bug bounty 程序
。
与以往一样,使用开放源码文档,请帮助我们扩展这个部分 (特别是有些例子不会有伤害)!
注意:除了下面的列表外,您可以找到更多的安全建议和最佳实践
从 Guy Lando 知识列表 <https://github.com/guylando/KnowledgeLists/blob/master/EthereumSmartContracts.md>
_ 和
the Consensys GitHub repo <https://consensys.github.io/smart-contract-best-practices/>
_.
陷阱
私有信息和随机性
您在智能合约中使用的一切都是公开可见的,甚至是
本地变量和状态变量标记 private
。
如果你不想矿工能欺骗的话,在智能合约中使用随机数字是非常困难的 。
重入攻击
来自A合约和另一个B合约的任意交互和任意ETH转账 会将控制权移到合约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”的gas限制,这个问题在这里并不太严重。
但它仍然暴露了一个弱点:Ether 转账可以随时
包括代码执行,因此收件人可以是一个合约,它可以调回
到“withdraw”。 这将使它能够获得多笔退款和
基本上可以弄走合约中所有的ETH。 特别是,
接下来的合约将允许攻击者多次退款,
因为它使用 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;
}
}
为了避免重放,您可以使用 Checks-Effects-Interactions 模式, 显示如下:
// 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);
}
}
Checks-Effects-Interactions 模式确保通过合约的所有代码路径完成所有必要的针对上游参数的检查 ,这个检查在修改合约状态之前(Checks);然后它才对状态作出任何更改(Effects); 它可以调用其它合同中的功能,在所有计划的状态更改都已写入到存储之后 (Interactions)。 这是一种常用有效的防止重放攻击的办法,在这种攻击中,一个外部叫做的 恶意合约能够使用回调最后一次使用的原始合约的逻辑来重复支付津贴,同时提取余额 。
请注意,重放不仅仅是影响Ether转账,而且也影响任何调用其他合约方法 。 此外,您还必须 将多合约情况考虑在内。 被调用的合约可能会修改 您所依赖的另一个合约的状态。
Gas限制和循环
没有固定的迭代次数的循环,例如依赖存储值的循环,必须小心使用:
由于区块gas限制,交易只能消耗一定数量的gas。 明确或仅归因于
普通操作,循环中的迭代次数可能会超出区块的gas限制,从而导致完整的
合约在某一时刻被停顿。 这可能不适用于只执行的 view
函数来从区块链里读数据的情况
。 但其他合约仍可将这类函数作为链上业务的一部分来调用
并且都被堵住。 请在你的合约文件中明确说明这类情况。
发送和接收Ether
-
合约和“外部账户”目前都无法阻止有人给他们发送Ether。 合约可以对正在进行的转账作出反应并拒绝正常转账,但有一些方式 移动Ether 而无需创建消息调用。 一种方法是简单地“mine to” 到合约地址,第二种方式使用
selfdestruct(x)
。 -
如果合约收到Ether (无一函数被调用), 要么 :ref:
receive Ether <receive-ether-function>
或 :ref:“fallback ” 函数已被执行。 如果它没有接收函数,也没有回退函数,则Ether 将 被拒(靠抛出异常)。 在执行这些函数其中之一时, 合同只能依靠在那时可用的它所通过的“Gas津贴”(2300 gas) 。 此津贴不足以修改 存储(不认为这是理所当然的,但津贴可能会随着未来的硬分叉而改变 。 确保你的合约能以此方式里获得Ether ,检查接收和回退函数的gas要求 (例如,在 Remix中的“详细信息”部分)。 -
有一种方法可以通过使用这种方法将更多的gas转到接收合同中
addr.call{value: x}("")
. 这基本上与addr.transfer(x)
相同, 只不同的是,传送所有剩余的gas并为其开启能力 ,为接收者执行更昂贵的操作 (并返回一个失败代码 而不是自动传播错误)。 这可能包括回调 你可能没有想到的发送合约或其他状态变更。 因此,它不仅为诚实的用户,而且也为恶意行为者提供了很大的灵活性。 -
使用最精确的单位来尽可能表示wei,因为你丢失 任何因不准确而四舍五入的任何结果。
-
如果你想使用
address.transfer
发送Ether ,那么有些细节可以知道:
- 如果收件人是合约,它会导致它的接收或回退函数 被执行后可以调回发送方合约。
- 发送Ether 可能由于调用深度超过1024而失败。 因为
呼叫者完全控制呼叫深度,他们可以强制
转账失败;考虑到这种可能性或使用
send
和 确保始终检查其返回值。 更好,写您的 合约时,使用收件人可以取出Ether 的模式。 - 发送Ether 也可能失败,因为收件人合约的执行
要求的Gas数量大于分配给它的数量(明确
使用 :ref:
require <assert-and-require>
, :ref:assert <assert-and-require>
, :ref:retrieve <assert-and-require>
,或因为是 操作太昂贵了——它“耗尽Gas”(OOG)。 如果你 使用transfer
或send
进行返回值检查,这可能是 为收件人提供阻止发送合约中的进度的手段 。 同样,这里的最佳做法是使用 :ref:“withdraw” 模式而不是“send”模式 <withdrawal_pattern>`.
调用堆栈深度
外部函数调用可能随时失败,因为它们超过了最大值
,呼叫堆栈大小限制为1024。 在这种情况下,Solidity会抛出异常。
恶意行为者可能会强迫呼叫堆栈达到很高的值
在他们与你的合约交互之前。 请注意,既然Tangerine Whistle <https://eips.ethereum.org/EIPS/eip-608>
_ 硬叉,那么63/64规则 <https://eips.ethereum.org/EIPS/eip-150>
_ 使呼叫堆栈深度攻击变得不切实际了。 还注意,呼叫堆栈和表达式堆栈不相关,尽管两者的大小限制都是1024个堆栈。
请注意,如果调用堆栈是 .send()
方法,则不 抛出一个异常
在这种情况下返回 false
。 低级的函数
.call()
、.delegatecall()
和 .staticall()
的行为方式相同。
授权代理
如果您的合约可以作为代理人,也就是说: 如果它可以调用任意合同 且使用用户提供的数据,然后用户基本上可以假定身份 为这个代理合约。 即使你已经采取了其他保护措施。 最好是建立您的合约系统,使代理不存在 任何权限(甚至不包括自身的权限)。 如果需要,您可以完成该操作 靠使用第二代理:
// 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);
}
}
现在有人让你把Ether 送到这个攻击钱包的地址:
// 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
,它将得到原始地址来踢出交易,而这仍然是所有者的地址。 攻击钱包会立即消耗您的所有资金。
.. _underflow-overflow:
双方完成/不足/溢出
与许多编程语言一样,Solidity的整数类型实际上不是整数。 当值较小时,它们与整数相似,但不能代表任意的大数字。
以下代码导致溢出,因为添加结果过大
要存储在 uint8
类型中:
.. code-block:: solidity
uint8 x = 255; uint8 y = 1; return x + y;
Solidity有两种模式来处理这些溢出:checked或unchecked或“包装”模式。
默认checked模式将检测到流量过多并导致故障断言。 您可以禁用此检查
靠使用 uncheck
… },造成溢出被静默忽略。 上述代码将返回
0如果被包裹在
uncheck 中... }
.
即使在checked的模式下,也不要假定您受到保护以免出现溢出漏洞。 在这个模式下,overflow总是还原。 如果无法避开 overflow,这可能会导致智能合约被卡住在一定的状态。
一般而言,读到两方完成代表的限制,甚至 对于有符号数字更特殊的边界。
尝试使用 require
来限制输入的大小到一个合理的范围并使用
:ref:SMT checker<smt_checker>
以发现潜在的溢出。
.. _clearing-mappings:
清除映射
Solidity 类型 mapping
(见 :ref:mapping-types
) 是仅存储的
key value 数据结构,它不保持对key的追踪,
特别是有0值的时候。 正因为如此,清理mapping但没有额外
写入key的信息是不可能的。
如果一个 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)
则返回 256,
虽然没另一次调用 writeMap
。
如果您的 mapping
信息必须被删除,请考虑使用类似的库:
iterable mapping <https://github.com/ethereum/dapp-bin/blob/master/library/iterable_mapping.sol>
_,
允许您在相应的 mapping
中穿越键并删除其值。
次要细节
- 不占用全部32字节的类型可能包含“脏更高的排序位”。
如果您访问
msg.data
,那么这一点就特别重要了——它会造成一种可恶性风险: 你可以用一个原始字节参数来制作调用函数f(uint8 x)
的交易 使用0xff000001
和0x000001
。 这两者都被输入合同,并且都将 看起来就像是x
的数字的1
,但是msg.data
将会 是不同的,所以如果你使用keccak256(msg.data)
来做任何事情,你会得到不同的结果。
建议
认真处理警告
如果编译器警告您要改变它。 即使你不认为这个特定的警告具有安全 问题,可能会有另一个问题被掩盖在这个问题下。 我们发布的任何编译器警告可能会因为略微修改代码而被静音 。
总是使用最新版本的编译器来通知所有最近的 引入警告。
编译器发布的 info
类型的消息是不危险的,只是简单的
表示编译器认为的额外建议和可选信息
,可能对用户有用。
限制Ether 的数量
限制可以存储在智能合约中的以太数量(或其他token) 。 如果你的源代码、编译器或平台有一个bug,那么这些 资金可能丢失。 如果你想限制你的损失,限制Ether的数量。
保持小且模块化
让您的合约更小,更容易理解。 单独退出无关的 其他合约或库中的功能。 一般建议 关于源代码应用的质量:限制本地变量的数量 函数的长度,等等。 记录您的函数以便其他人使用 可以看到你的意图以及它是否不同于代码的行为。
使用Checks-Effects-Interactions模式
大多数函数将首先进行一些检查(称为函数 是范围内的参数,他们是否发送足够的Ether,是否执行人 有token等。) 这些检查应首先进行。
作为第二步,如果所有检查都通过了,对状态变量的影响 ,是否是当前合约应该有的变化。 与智能合约交互 应该是任何函数中的最后一步。
早期合约推迟了一些效果并等待外部函数 调用返回一个非错误的状态。 这常常是一个严重错误 由于上文解释的重放问题。
请注意,对已知合同的调用可能反过来导致调用 未知合约,所以最好总是应用此模式。
包括失败安全模式
在使您的系统完全分散时,将删除任何中介。 可能是一个好主意,尤其是对于新的代码来说,列入某种类型 故障安全机制:
您可以在智能合同中添加一个可以执行一些功能的函数 ,包括自我检查类似于“是否有任何Ether 泄漏?”, “令牌的总和是否等于合同的余额?”或类似的东西。 请记住,您不能为此使用太多的gas,这样就可以通过链下计算的方式提供帮助 。
如果自我检查失败,合约会自动切换到 “故障安全”模式下,该模式例如禁用大部分功能,将会转发到 控制固定和受信任的第三方或只是将合同转换为 一个简单的"给我退回我的钱"合约。
请求同行审核
检查一部代码的人越多,发现的问题就越多。 要求人们查看您的代码也有助于交叉检查,了解您的代码 是不是很容易理解――这是良好合同的一个非常重要的标准。
John translated from https://docs.soliditylang.org/en/latest/security-considerations.html#recommendations