安全考虑
虽然构建可按预期工作的软件通常非常容易,但要检查没有人能够以没有预料到的方式使用它,要困难得多。
在Solidity中,这更加重要,因为您可以使用智能合约来处理令牌或可能更有价值的东西。此外,智能合约的每一次执行都在公开场合进行,除此之外,源代码通常是可用的。
当然,你总是需要考虑有多大的风险:你可以将智能合约与对公众开放的Web服务(以及对恶意行为者)以及甚至开放源代码进行比较。如果您只在该Web服务上存储您的购物清单,则可能不必太在意,但如果您使用该Web服务管理您的银行帐户,则应该更加小心。
本节将列出一些陷阱和一般安全建议,但当然可能永远不会完整。另外,请记住,即使您的智能合约代码没有缺陷,编译器或平台本身也可能有错误。可以在已知错误列表中找到编译器的一些公开已知安全相关错误 列表,这些错误也是机器可读的。请注意,有一个错误赏金程序涵盖了Solidity编译器的代码生成器。
与往常一样,使用开源文档,请帮助我们扩展本节(特别是,一些示例不会受到伤害)!
陷阱
私人信息和随机性
您在智能合同利用一切是公开可见的,甚至是局部变量和标记状态变量private
。
如果你不希望矿工能够作弊,在智能合同中使用随机数字是相当棘手的。
再入境
合同(A)与另一合同(B)的任何互动以及乙方的任何转让均将控制移交给该合同(B)。这使得在这个交互完成之前B可以回调A. 举一个例子,下面的代码包含一个错误(它只是一个片段而不是一个完整的合同):
pragma solidity ^0.4.0;
// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
/// Mapping of ether shares of the contract.
mapping(address => uint) shares;
/// Withdraw your share.
function withdraw() public {
if (msg.sender.send(shares[msg.sender]))
shares[msg.sender] = 0;
}
}
这里的问题不是太严重,因为有限的天然气是其中的一部分send
,但它仍然暴露出一个弱点:以太转移总是可以包含代码执行,因此接收方可以是一个重新回调的合同withdraw
。这将让它得到多个退款,并基本上检索合同中的所有以太网。特别是,下面的合同将允许攻击者在使用call
默认情况下多次退还所有剩余的气体:
pragma solidity ^0.4.0;
// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
/// Mapping of ether shares of the contract.
mapping(address => uint) shares;
/// Withdraw your share.
function withdraw() public {
if (msg.sender.call.value(shares[msg.sender])())
shares[msg.sender] = 0;
}
}
为了避免重新入侵,您可以使用Checks-Effects-Interactions模式,详情如下:
pragma solidity ^0.4.11;
contract Fund {
/// Mapping of ether shares of the contract.
mapping(address => uint) shares;
/// Withdraw your share.
function withdraw() public {
var share = shares[msg.sender];
shares[msg.sender] = 0;
msg.sender.transfer(share);
}
}
请注意,重入不仅是以太转移的影响,而且是任何其他合同的功能调用的影响。此外,您还必须考虑多合同情况。被叫合同可以修改您依赖的另一份合同的状态。
气体限制和循环
没有固定迭代次数的循环(例如取决于存储值的循环)必须小心使用:由于块气体限制,交易只能消耗一定量的气体。无论是明确的还是仅仅由于正常的操作,循环中的迭代次数可能会超出堵塞气体限制,这会导致整个合同在某个点停滞。这可能不适用于view
仅用于从区块链中读取数据的函数。尽管如此,这些功能可能会被其他合同作为链上操作的一部分进行调用,并将其拖延。请在合同文件中明确说明这些情况。
发送和接收以太
合同和“外部账户”都不能阻止有人送他们以太。合同可以作出反应并拒绝定期转移,但有些方法可以在不创建消息呼叫的情况下移动以太网。一种方法是简单地将我的“合同地址”和第二种方式使用
selfdestruct(x)
。如果合同收到Ether(没有调用函数),则执行回退函数。如果它没有后备功能,Ether将被拒绝(通过抛出异常)。在执行回退功能时,合同只能依靠当时可用的“天然气津贴”(2300天然气)。这笔津贴不足以以任何方式访问存储。为确保您的合同能够以此方式接收Ether,请检查故障预置功能的气体要求(例如,在Remix的“详细信息”部分中)。
有一种方法可以使用更多的天然气到接收合同
addr.call.value(x)()
。这基本上与它相同addr.transfer(x)
,只是它转发所有剩余的气体并打开接收方执行更昂贵的操作的能力(并且它仅仅返回失败代码并且不会自动传播错误)。这可能包括回拨发送合约或您可能没有想到的其他状态更改。因此它为诚实用户提供了极大的灵活性,同时也为恶意行为者提供了很大的灵活性如果你想发送以太网
address.transfer
,有一些细节需要注意:如果收件人是合同,它将导致其执行回退功能,从而可以回拨发送合同。
发送以太网可能会因呼叫深度超过1024而失败。由于呼叫者完全控制呼叫深度,因此可能会强制传送失败; 考虑这种可能性或使用
send
并确保始终检查其返回值。更好的是,用收款人可以取消Ether的模式写下你的合同。发送醚也可能会失败,因为收件人合同的执行需要比气体的分配金额(显式地使用
require
,assert
,revert
,throw
或因为操作太昂贵) -它“耗尽气”(OOG)。如果您使用transfer
或send
使用返回值检查,这可能为收件人提供阻止发送合同中进度的手段。同样,这里的最佳做法是使用“撤回”模式而不是“发送”模式。
Callstack
外部函数调用可能会随时失败,因为它们超过了1024的最大调用堆栈。在这种情况下,Solidity会引发异常。恶意行为者在与你的合同进行交互之前可能会强制调用堆栈的价值很高。
请注意,如果调用堆栈已耗尽,但在此情况下返回,.send()
则不会抛出异常false
。低层次的功能.call()
,.callcode()
并.delegatecall()
以相同的方式表现。
tx.origin
切勿使用tx.origin进行授权。假设你有这样的钱包合约:
pragma solidity ^0.4.11;
// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract TxUserWallet {
address owner;
function TxUserWallet() public {
owner = msg.sender;
}
function transferTo(address dest, uint amount) public {
require(tx.origin == owner);
dest.transfer(amount);
}
}
现在有人欺骗你将乙醚发送到这个攻击钱包的地址:
pragma solidity ^0.4.11;
interface TxUserWallet {
function transferTo(address dest, uint amount) public;
}
contract TxAttackWallet {
address owner;
function TxAttackWallet() public {
owner = msg.sender;
}
function() public {
TxUserWallet(msg.sender).transferTo(owner, msg.sender.balance);
}
}
如果你的钱包已经检查msg.sender
过授权,它将得到攻击钱包的地址,而不是所有者地址。但通过检查tx.origin
,它会得到启动交易的原始地址,该交易仍是所有者地址。攻击钱包立即消耗您的所有资金。
次要细节
其中,类型将是,因为这是保存该值所需的最小类型。如果数组有超过255个元素,则循环不会终止。
for (var i = 0; i < arrayName.length; i++) { ... }iuint80
不占用完整32字节的类型可能包含“脏高位”。如果您访问这一点,这一点尤其重要
msg.data
- 它带来了可扩展性风险:您可以创建调用函数的事务,其原始字节参数为和。两者都被纳入合同,两者看起来都像数字一样,但会有所不同,所以如果您使用任何东西,您将得到不同的结果。f(uint8 x)0xff0000010x000000011xmsg.datakeccak256(msg.data)
建议
认真对待警告
如果编译器警告你某些事情,你应该更好地改变它。即使你不认为这个特定的警告具有安全意义,也可能会有另一个隐藏在其下面的问题。我们发布的任何编译器警告都可以通过对代码的轻微更改来消除。
也可以尝试通过添加来尽早启用“0.5.0”安全功能。请注意,在这种情况下,这个词并不意味着安全功能有任何风险,它只是一种启用一些功能的方法,这些功能由于向后兼容性而不属于最新版本的Solidity的一部分。pragma experimental "v0.5.0";experimental
限制以太的数量
限制可以存储在智能合约中的以太网(或其他令牌)数量。如果您的源代码,编译器或平台有错误,这些资金可能会丢失。如果你想限制你的损失,限制乙醚的数量。
保持小而模块化
保持合同规模小,易于理解。在其他合同或库中找出无关的功能。关于源代码质量的一般建议当然适用:限制局部变量的数量,函数的长度等等。记录你的功能,以便其他人可以看到你的意图是什么,以及它是否与代码不同。
使用Checks-Effects-Interactions模式
大多数函数将首先执行一些检查(谁调用函数,是范围内的参数,他们是否发送了足够多的Ether,人员是否具有令牌等)。这些检查应该先完成。
作为第二步,如果所有检查都通过了,则应该对当前合同的状态变量产生影响。与其他合同的交互应该是任何功能的最后一步。
早期合同延迟了一些效果,并等待外部函数调用以非错误状态返回。由于上述重入问题,这通常是一个严重的错误。
请注意,对已知合同的调用也可能导致对未知合同的调用,所以最好始终应用此模式。
包含故障安全模式
在使系统完全分散化的同时将删除任何中介,这可能是一个好主意,特别是对于新代码,可能包含某种故障安全机制:
您可以在智能合约中添加一个函数,该函数执行一些自我检查,例如“有任何乙醚泄露?”,“令牌总数是否等于合同余额?”或类似的东西。请记住,你不能使用太多的气体,所以通过脱链计算可能需要帮助。
如果自检失败,合同会自动切换到某种“故障安全”模式,例如禁用大多数功能,将控制权交给固定和可信的第三方,或者仅将合同转换为简单的“把我的钱还给我“合同。
正式验证
使用形式化验证,可以执行自动化的数学证明,证明源代码符合特定的正式规范。规范仍然是正式的(就像源代码一样),但通常要简单得多。
请注意,形式验证本身只能帮助你理解你所做的事情(规范)和你如何做(实际实现)之间的差异。您仍然需要检查规格是否是您想要的,并且您没有错过任何意想不到的效果。