智能合约审计是一个仔细调查一段代码的过程,在这种情况下是一个solidity合约,用于在代码部署之前发现错误、漏洞和风险。
审计的结构:
- 免责申明:在此您可以说审计不是具有法律约束力的文件,并且不保证任何内容。这只是一个讨论文件。
- 审计和优秀功能概述:快速查看将要审计的智能合约以及找到良好实践。
- 对合约的攻击:在本节中,您将讨论对合约和结果所做的攻击。只是为了验证它确实是安全的。
- 合约中发现的严重漏洞:可能严重损害合约完整性的关键问题。一些允许攻击者窃取ether的错误是一个关键问题。
- 合约中发现的中等漏洞:可能损害合约但存在某种限制的漏洞。就像一个允许人们修改随机变量的bug。
- 发现低严重性漏洞:这些漏洞并未真正损害合约,并且可能存在于合约的部署版本中。
- 逐行注释:在本节中,您将分析最重要的行,您可以看到潜在的改进。
- 审计摘要:您对合约的意见和有关审计的最终结论。
免责申明
审计不对代码的实用性、代码的安全性、业务模型的适用性、业务模型的监管制度、或任何其他关于合约适用性或其无bug状态的陈述作出陈述或保证。审核文档仅供讨论之用。
常见的攻击类型以及注意事项
-
私人信息和随机性:在智能合约使用的所有内容都是公开可见的,甚至是标记的局部变量和状态变量
private
。如果你不希望矿工能够作弊,在智能合约中使用随机数是非常棘手的。 - Re-Entrancy(重入):为避免重新入侵,您可以使用Checks-Effects-Interactions模式
call.value()如果用户balance在发送ether之前没有更新发送方,
则此攻击包括递归调用ERC20 token中的方法以提取存储在合约上的ether。
❌ if (msg.sender.call.value(shares[msg.sender])(""))
shares[msg.sender] = 0;
❌ if (msg.sender.send(shares[msg.sender]))
shares[msg.sender] = 0;
✔️ uint share = shares[msg.sender];
shares[msg.sender] = 0;
msg.sender.transfer(share);
- 变量过大和过低:当超出类型变量uint256的限制2**256时发生溢出。会发生什么?是该值重置为零而不是递增更多。建议使用像OpenZeppelin的SafeMath.sol这样的库。
import './SafeMath.sol';
contract Casino {
using SafeMath for uint256;
function example(uint256 _value) {
uint number = msg.value.add(_value);
}
}
- Replay(重放攻击):它不再是问题,因为Geth版本1.5.3和Parity 1.4.4都实现了Vitalik Buterin的攻击保护EIP155
- Reordering attack(重组攻击):这种攻击在于,矿工或其他方试图通过将自己的信息插入列表或映射来与智能合约参与者“竞赛”,以便攻击者可以幸运地将他们自己的信息存储在合约中。
- 短地址攻击:此攻击影响ERC20 token。购买令牌时,请务必检查地址的长度。合约不易受此攻击,因为它不是ERC20 token。参考:https://vessenes.com/the-erc20-short-address-attack-explained/
-
assert()
和require()
行为几乎完全相同,但assert()用于在进行更改后验证合约状态,而require()通常用于函数顶部以验证函数的输入。 - 调用堆栈深度:1024。外部函数调用可能会随时失败,在这种情况下,Solidity会抛出异常。恶意攻击者可能会在与您的合约交互之前强制调用堆栈达到较高值。请注意,如果调用堆栈耗尽,
.send()
则不会抛出异常,而是false
在这种情况下返回。低级别的功能.call()
,.callcode()
以及.delegatecall()
行为以同样的方式。 - 切勿使用
tx.origin
进行授权。
安全建议
外部调用
尽量避免外部调用:调用不受信任的外部合约可能会引发一系列意外的风险和错误。外部调用可能在其合约和它所依赖的其他合约内执行恶意代码。
-
仔细权衡
send()
、transfer()
、call.value()
:在需要对外未知地址转账Ether时使用send()
或transfer()
,已知明确内部无恶意代码的地址转账Ether使用call.value()
。-
x.transfer(y)
和if(!x.send(y)) throw;
是等价的。send是transfer的底层实现,建议尽可能直接使用transfer。 -
someAddress.send()
和someAddress.transfer()
能保证可重入安全。尽管这些外部智能合约的函数可以被触发执行,但补贴给外部智能合约的2,300 gas,意味着仅仅只够记录一个event到日志中。 -
someAddress.call.value()
将会发送指定数量的Ether并且触发对应代码的执行。被调用的外部智能合约代码将享有所有剩余的gas,通过这种方式转账是很容易有可重入漏洞的,非常不安全。
-
-
处理外部调用错误:如果你选择使用底层方法,如
call()
、callcode()
、delegatecall()
等,一定要检查返回值来对可能的错误进行处理。// bad someAddress.send(55); someAddress.call.value(55)(); // this is doubly dangerous, as it will forward all remaining gas and doesn't check for result someAddress.call.value(100)(bytes4(sha3("deposit()"))); // if deposit throws an exception, the raw call() will only return false and transaction will NOT be reverted // good if(!someAddress.send(55)) { // Some failure code } ExternalContract(someAddress).deposit.value(100);
不要假设你知道外部调用的控制流程:无论是使用
raw calls
或是contract calls
,如果这个ExternalContract
是不受信任的都应该假设存在恶意代码。即使ExternalContract
不包含恶意代码,但它所调用的其他合约代码可能会包含恶意代码。一个具体的危险例子便是恶意代码可能会劫持控制流程导致竞态。对于外部合约优先使用pull 而不是push:外部调用可能会有意或无意的失败。为了最小化这些外部调用失败带来的损失,通常好的做法是将外部调用函数与其余代码隔离,最终是由收款发起方负责发起调用该函数。
标记不受信任的合约:当你自己的函数调用外部合约时,你的变量、方法、合约接口命名应该表明和他们可能是不安全的。
其他
- 使用assert()强制不变性:当断言条件不满足时将触发断言保护 -- 比如不变的属性发生了变化。
-
正确使用assert()和require():
require(condition)
被用来验证用户的输入,如果条件不满足便会抛出异常,应当使用它验证所有用户的输入。assert(condition)
在条件不满足也会抛出异常,但是最好只用于固定变量:内部错误或你的智能合约陷入无效的状态。 - 小心整数除法的四舍五入:所有整数除数都会四舍五入到最接近的整数。 如果您需要更高精度,请考虑使用乘数,或存储分子和分母。
- 记住Ether可以被强制发送到账户:谨慎编写用来检查账户余额的不变量。
- 不要假设合约创建时余额为零:攻击者可以在合约创建之前向合约的地址发送wei。合约不能假设它的初始状态包含的余额为零。
- 记住链上的数据是公开的:许多应用需要提交的数据是私有的,直到某个时间点才能工作。如果你的应用存在隐私保护问题,一定要避免过早发布用户信息。
- 权衡Abstract合约和Interfaces:Interfaces和Abstract合约都是用来使智能合约能更好的被定制和重用。Interfaces和Abstract合约很像但是不能定义方法只能申明。Interfaces存在一些限制比如不能够访问storage或者从其他Interfaces那继承,通常这些使Abstract合约更实用。尽管如此,Interfaces在实现智能合约之前的设计智能合约阶段仍然有很大用处。另外,需要注意的是如果一个智能合约从另一个Abstract合约继承而来那么它必须实现所有Abstract合约内的申明并未实现的函数,否则它也会成为一个Abstract合约。
- 在双方或多方参与的智能合约中,参与者可能会“脱机离线”后不再返回:不要让退款和索赔流程依赖于参与方执行的某个特定动作而没有其他途径来获取资金。
- 使Fallback函数尽量简单:谨慎编写fallback函数以免gas不够用。
-
明确标明函数和状态变量的可见性:明确标明函数和状态变量的可见性。函数可以声明为
external
,public
,internal
或private
。 - 将程序锁定到特定的编译器版本:智能合约应该应该使用和它们测试时使用最多的编译器相同的版本来部署。锁定编译器版本有助于确保合约不会被用于最新的可能还有bug未被发现的编译器去部署。智能合约也可能会由他人部署,而pragma标明了合约作者希望使用哪个版本的编译器来部署合约。
- 小心分母为零 (Solidity < 0.4)
- 区分函数和事件:为了防止函数和事件(Event)产生混淆,命名一个事件使用大写并加入前缀(我们建议LOG)。对于函数,始终以小写字母开头,构造函数除外。
- 使用Solidity更新的构造器
开发理念
-
对可能的错误有所准备
- 有效的途径来进行bug修复和功能升级
-
谨慎发布智能合约
- 阶段性发布,每个阶段都提供足够的测试
-
保持智能合约简洁
- 确保智能合约逻辑简洁
- 确保合约合函数模块化
- 使用已经被广泛使用的合约或工具(比如,不要自己写一个随机生成器)
- 条件允许的话,清晰明了比性能更重要
- 只在你系统的去中心化部分使用区块链
-
保持更新
- 在任何新的漏洞被发现时检查你的智能合约
- 尽可能快的将使用到的库或者工具更新到最新
- 使用最新的安全技术
-
清楚区块链的特性
- 特别小心针对外部合约的调用,因为你可能执行的是一段恶意代码然后更改控制流程
- 清楚你的public function是公开的,意味着可以被恶意调用。(在以太坊上)你的private data也是对他人可见的
- 清楚gas的花费和区块的gas limit
开发建议
- 严肃对待:如果编译器警告你某事,应该更好地改变它。
- 限制ether的数量:限制可以存储在智能合约中的ether(或其他token)的数量。如果您的源代码、编译器或平台有错误,这些资金可能会丢失。
- 保持简洁而且模块化
- 使用Checks-Effects-Interactions模式
- 包含故障安全模式:如果自检失败,合约会自动切换到某种“故障保护”模式,例如,禁用大多数功能,将控制交给固定和可信任的第三方,或者只是将合约转换为简单的“把我的钱还给我“合约。
- 规范验证:证明源代码符合某种正式规范。