智能合约极为灵活,能够控制大量的价值和数据,并在区块链上运行基于代码的不可改变逻辑。 因而,一个由去信任的去中心化应用程序构成的生态系统应运而生且充满活力,它具备了许多传统系统所没有的优势。 同时,这也给攻击者提供了利用智能合约中的漏洞来获利的机会。

公共区块链(比如以太坊)使智能合约的安全性问题变的更加复杂。 已部署的合约代码_通常_无法更改因而不能给安全问题打补丁,并且由于这种不可变性,从智能合约中盗取的资产极难追踪并且绝大多数无法挽回。

虽然统计数据有所差异,但据估计,由于智能合约的安全缺陷而被盗窃或丢失的资产总额肯定超过了 10 亿美元。 其中包括几次著名事件,比如 DAO 攻击事件opens in a new tab(360 万个以太币被盗,按照当前价格计算总金额超过 10 亿美元)、Parity 多重签名钱包攻击事件opens in a new tab(黑客窃取了 3000 万美元)以及 Parity 钱包冻结问题opens in a new tab(价值超过 3 亿美元的以太币遭到永久锁定)。

上述几个事件迫使开发者必须付诸努力,构建安全、稳健、恢复力强的智能合约。 智能合约安全性是每个开发者都需要学习和研究的严肃问题。 本指南将介绍针对以太坊开发者的安全性注意事项,并研究增强智能合约安全性的资源。

前言

在开始研究安全性问题之前,请确保自己已经熟悉智能合约开发的基础知识。

安全以太坊智能合约的构建准则

1. 设计合理的访问控制

publicexternalprivate

为了防止未经授权使用智能合约函数,有必要实现安全访问控制。 访问控制机制将使用智能合约中某些特定函数的能力限定给经过核准的实体,例如负责管理合约的帐户。 两种模式有助于在智能合约中实现访问控制,所有权模式基于角色的控制

所有权模式

OnlyOwner

基于角色的访问控制

Owner

在基于角色的访问控制中,对敏感函数的访问分布在一组受信任的参与者之间。 例如,一个帐户可能负责铸造代币,而另一个帐户进行升级或暂停合约。 以这种方式分散访问控制,消除了单点故障并减少了对用户的信任假设。

使用多重签名钱包

实施安全访问控制的另一种方法是使用多重签名帐户来管理合约。 与常规外部帐户不同,多重签名帐户由多个实体拥有,需要最低数量的帐户签名(比如 5 个中的 3 个)才能执行交易。

使用多重签名进行访问控制增加了额外一层安全性保障,因为需要多方同意才能对目标合约执行操作。 如果有必要使用所有权模式,这种方法尤其有用,因为攻击者或内部作恶者操控敏感的合约函数以达到恶毒目的会更加困难。

2. 使用 require()、assert() 和 revert() 语句保护合约操作

require()assert()revert()
require()requirerequire
assert()assert()assert()
revert()revert()revert()

3. 测试智能合约并验证代码正确性

鉴于在以太坊虚拟机中运行的代码的不可变性,智能合约在开发阶段需要更高水平的质量评估。 对合约进行大量测试并观察是否存在任何意外结果,将显著增强合约的安全性并为用户提供长远保护。

常用办法编写小单元测试,这些测试使用预计合约从用户处接收的模拟数据。 单元测试能够测试某些函数的功能并确保智能合约按预期运行。

遗憾的是,单独使用单元测试对提高智能合约的安全性效果甚微。 单元测试也许可以证明函数对于模拟数据正确执行,但单元测试的有效性受限于编写的测试。 这就意味着很难检测到威胁智能合约安全性的边缘情况和漏洞。

更好的方法是将单元测试与基于属性的测试相结合,后者是通过静态和动态分析进行的。 静态分析依赖于底层的表示(例如控制流程图opens in a new tab和抽象语法树opens in a new tab)分析可达到的程序状态和执行路径。 同时,动态分析技术(例如智能合约模糊测试opens in a new tab)使用随机输入值执行合约代码,以检测违反安全属性的操作。

形式化验证是另一项验证智能合约安全属性的技术。 与常规测试不同,形式化验证能够确证智能合约中没有错误。 这是通过制定细致描述安全属性的形式化规范并证明智能合约的形式化模型符合这一规范来实现的。

4. 申请代码独立审核

在测试智能合约后,最好请其他人检查源代码是否存在安全问题。 虽然测试无法发现智能合约中的所有缺陷,但进行独立审核能增加发现漏洞的可能性。

审计

进行独立代码审核的方式之一是委托执行智能合约审计。 审计员是确保智能合约安全、没有质量缺陷和设计错误的关键所在。

尽管如此,你也不应将审计看作终极方案。 智能合约审计无法发现所有漏洞并且主要是为了额外增加一轮审核,这有助于检测到开发者在最初的开发和测试中遗漏的问题。 你还应遵循与审计员合作的最佳做法(例如正确记录代码并添加行内注释),让智能合约审计发挥最大作用。

漏洞奖励

执行外部代码审查的另一种方法是设立漏洞奖励计划。 漏洞奖励是一种经济奖励,提供给发现应用程序中漏洞的个人(通常是白帽黑客)。

应用得当,漏洞奖励可以激励黑客群体中的成员检查你的代码是否存在重大缺陷。 一个真实的示例是“无限复制倾向漏洞”,它可以让攻击者在以太坊上运行的二层网络协议 Optimismopens in a new tab 上创建无限量的以太币。 幸运的是,一位白帽黑客发现了这一漏洞opens in a new tab并告知了以太坊团队,并获得了一大笔报酬opens in a new tab

一种实用策略是按有风险资金数额的比例设置漏洞奖励计划的报酬金额。 这种方法被描述成“比例漏洞奖励opens in a new tab”,通过提供经济激励让大家负责任地披露而非利用漏洞。

5. 智能合约开发过程中遵循最佳做法

即使审计和漏洞奖励存在,你也有责任编写高质量的代码。 遵循正确的设计和开发流程是良好的智能合约安全性的开端:

  • 将所有代码存放在一个版本控制系统中,例如 git

  • 所有代码修改通过拉取请求进行

  • 确保拉取请求至少有一位独立审核者 — 如果只有你一人完成项目,考虑和其他开发者相互进行代码审核

  • 在开发环境下测试、编译、和部署智能合约

  • 通过基本代码分析工具运行代码,例如 Cyfrin Aderynopens in a new tab 、Mythril 和 Slither。 理想情况下,应在合并每个拉取请求前进行这一操作,并比较输出中的不同之处

  • 确保代码在编译时没有错误,并且 Solidity 编译器没有发出警告

  • 正确记录代码(使用 NatSpecopens in a new tab),并用易于理解的语言描述合约架构的细节。 这将使其他人更容易审计和审核你的代码。

6. 实施可靠的灾难恢复计划

设计安全的访问控制、使用函数修改器以及其他建议能够提高智能合约的安全性,但这些并不能排除恶意利用的可能性。 构建安全的智能合约需要“做好失败准备”,并制定好应变计划有效地应对攻击。 适当的灾难恢复计划应包括以下部分或全部内容:

合约升级

虽然以太坊智能合约默认是不可变的,但通过使用升级模式可以实现一定程度的可变性。 如果重大缺陷导致合约不可用并且部署新逻辑是最可行的选择,有必要升级合约。

合约升级机制的原理有所不同,但“代理模式”是智能合约升级最常见的方法之一。 代理模式opens in a new tab将应用程序的状态和逻辑划分为_两个_合约。 第一个合约(称为“代理合约”)存储状态变量(如用户余额),而第二个合约(称为"逻辑合约")存放执行合约函数的代码。

delegatecall()delegatecall()msg.sendermsg.value

将调用委托给逻辑合约需要将其地址存储在代理合约的存储空间。 因此,升级合约的逻辑就相当于部署另一个逻辑合约并在代理合约中存储新的地址。 由于对代理合约的后续调用会自动传送到新的逻辑合约,因此你“升级”了合约,但实际上并未修改代码。

紧急停止

如上所述,大量审计和测试不可能发现智能合约中的所有漏洞。 无法修补在部署后出现的代码漏洞,因为你无法更改运行在智能合约中的代码。 而且,升级机制(如代理模式)可能需要时间来实现(它们往往需要多方批准),这只会给攻击者更多的时间来造成更大的破坏。

这种情况下,核心方案是实施一种“紧急停止”功能,阻止对合约中有漏洞的函数的调用。 紧急停止通常由以下几部分组成:

falsetruetrue

一旦合约操作触发紧急停止,某些函数将无法调用。 这是通过把一些函数包装在引用该全局变量的修改器中实现的。 以下示例opens in a new tab描述了该模式在合约中的实现:

以上示例展示了紧急停止的基本特点:

isStoppedfalsetrueonlyWhenStoppedstoppedInEmergencyisStoppedstoppedInEmergencydeposit()
onlyWhenStoppedemergencyWithdraw()

紧急停止功能的应用,为处理智能合约中的严重漏洞提供了一种有效的权宜之计。 然而,这也意味着用户更需要相信开发者不会为自身利益激活这一功能。 为此,将紧急停止的控制权去中心化,使其受到链上投票机制、时间锁的约束或者需要来自多重签名钱包的批准,都是潜在的解决方案。

事件监测

事件opens in a new tab允许用户跟踪对智能合约函数的调用并监测状态变量的变化。 最理想的做法是将智能合约编写为能够在某一方采取对安全至关重要的操作(如提取资金)时发出一个事件。

记录事件并进行链下监测,可以深入了解合同的运作情况,有助于更快地发现恶意行为。 这意味着你的团队可以更快地应对黑客攻击并采取行动减轻对用户的影响,如暂停函数或进行升级。

你也可以选择一种现成的监测工具,只要有人与你的合约交互,就会自动转发警报。 这些工具将允许你根据不同的触发器创建自定义警报,如交易量、函数调用的频率或相关具体函数。 例如,你可以编写一个警报,当单笔交易的提款金额超过特定阈值时触发。

7. 设计安全的治理系统

你可能想要通过将核心智能合约的控制权转交给社区成员来去中心化你的应用。 在这种情况下,智能合约系统将包括一个治理模块 — 一种允许社区成员通过链上治理系统批准管理行为的机制。 例如,将代理合约升级为新实现的提案可能由代币持有人投票。

去中心化治理可能是有益的,特别是因为它符合开发者和最终用户的利益。 然而如果实现不当,智能合约治理机制可能会带来新的风险。 一种可能的场景是,攻击者通过取得闪电贷获得了很大的投票权(以持有的代币数量衡量)并通过一条恶意提案。

防止与链上治理有关的问题的一种方法是使用时间锁opens in a new tab。 时间锁阻止智能合约执行某些操作,直到经过特定的时间长度。 其他策略包括根据每个代币锁定的时间长短为其分配“投票权重”,或者检测一个地址在历史时期(例如,过去的 2-3 个区块)而不是当前区块的投票权。 这两种方法都减少了快速累积投票权以影响链上投票的可能性。

8. 将代码的复杂性降到最低

传统的软件开发者熟悉 KISS(“保持简单、保持愚蠢”)原则,该原则建议不要将不必要的复杂性带入到软件设计中。 这与长期以来的见解“复杂的系统有着复杂的失败方式”不谋而合,而且复杂系统更容易出现代价高昂的错误。

编写智能合约时简洁化尤其重要,因为智能合约有可能控制大量的价值。 实现简洁化的一个窍门是,编写智能合约时在允许的情况下重用已存在的库,例如 OpenZeppelin Contractsopens in a new tab。 因为开发者对这些库已经进行了广泛的审计和测试,使用它们会减少从零开始编写新功能时引入漏洞的几率。

另一个常见的建议是通过将业务逻辑拆分到多个合约中,编写小型函数并保持合约模块化。 编写更简单的代码不仅仅会减少智能合约中的攻击面,还让推理整个系统的正确性并及早发现可能的设计错误变得更加容易。

9. 防范常见的智能合约漏洞

重入攻击

以太坊虚拟机不允许并发,这意味着消息调用中涉及的两个合约不能同时运行。 外部调用暂停调用合约的执行和内存,直到调用返回,此时执行正常进行。 该过程可以正式描述为将控制流opens in a new tab转向另一个合约。

尽管这种转向大多数情况下没有危害,但将控制流转向不受信任的合约可能引起问题,例如重入攻击。 当恶意合约在初始函数调用完成之前回调有漏洞的合约时,就会发生重入攻击。 这类攻击最好用一个例子来解释。

考虑一个简单的智能合约(“Victim”),它允许任何人存入和提取以太币:

withdraw()
  1. 检查用户的以太币余额
  2. 将资金发送给调用地址
  3. 将其余额重置为 0,防止用户再提取
Victimwithdraw()
withdraw()msg.sender.call.value()msg.senderwithdraw()msg.sender.call.value()

假设以下是部署在合约地址的代码:

此合约执行下面三项操作:

  1. 接受来自另一帐户(如攻击者的外部帐户)的存款
  2. 将 1 个以太币存入 Victim 合约
  3. 提取存储在该智能合约中的 1 个以太币
Attackermsg.sender.call.valueVictimwithdraw()AttackerVictimwithdraw

总结起来就是,由于调用者的余额在函数执行完成之前没有设置为 0,所以后续的调用会成功,让调用者可以多次提取他们的余额。 这种攻击可以用来提空智能合约中的资金,就像 2016 DAO 黑客攻击opens in a new tab中发生情况的那样。 正如公开的重入攻击列表opens in a new tab所示,当前重入攻击仍是智能合约所面临的一个严重问题。

如何防止重入攻击

应对重入攻击一种方法是遵循检查-效果-交互模式opens in a new tab。 这种模式要求按照以下方式执行函数:最先执行在继续执行函数前执行必要检查的代码,再执行操作合约状态的代码,最后执行与其他合约或外部帐户交互的代码。

Victim
withdraw()AttackerNoLongerAVictimbalances[msg.sender]
truefalse

还可以使用拉取支付opens in a new tab 系统,该系统要求用户从智能合约中提取资金,而不是使用将资金发送到帐户的“推送支付”系统。 这样就消除了意外触发未知地址中代码的可能性(还可以防止某些拒绝服务攻击)。

整数下溢和溢出

uint8255Uint0
uint80255

整数溢出和下溢都会导致合约的状态变量出现意外变化,引发意外的执行。 以下例子说明了攻击者如何利用智能合约的算数溢出执行无效操作:

如何防止整数溢出和下溢

从 0.8.0 版开始,Solidity 编译器禁用导致整数下溢和溢出的代码。 然而,用较低编译器版本编译的合约应当对涉及算术运算的函数执行检查,或者使用检查是否发生下溢/溢出的库(例如 SafeMathopens in a new tab)。

预言机操纵

预言机获取链下信息并将这些信息发送到链上供智能合约使用。 通过预言机,你可以设计出和链下系统(例如资本市场)交互的智能合约,极大地拓展它们的应用。

但如果预言机损坏并向链上发送错误信息,智能合约将基于错误的输入执行,这会造成问题。 这就是“预言机问题”的根源,它涉及确保区块链预言机提供准确、最新、即时的信息。

相关的安全问题就是利用链上预言机(例如去中心化交易所)获取一种资产的现货价格。 去中心化金融 (DeFi) 行业中的借贷平台经常利用这种方法确定用户抵押品的价值,进而确定他们能借入多少。

去中心化交易所 (DEX) 的价格往往是准确的,很大程度上源于套利者的套利行为帮助市场恢复平价。 然而,这样容易受到操纵,尤其当链上预言机根据历史交易模式计算资产价格时(通常是这种情况)。

例如,攻击者可以在与你的借贷合约交互前,通过获得闪电贷人为拉高资产的现货价格。 在向去中心化交易所 (DEX) 查询资产价格时,将返回一个高于正常水平的值(由于攻击者对大宗“买入订单”影响了资产的需求),这样攻击者就可以借来比原本更多的资金。 这种“闪电贷攻击”一直在利用对去中心化金融应用程序之间的价格预言机的依赖,使许多协议遭受了数百万美元的资金损失。

如何防止预言机操纵

避免预言机操纵opens in a new tab的最低要求是使用从多个来源查询信息的去中心化预言机网络,以避免单点故障。 在大多数情况下,去中心化预言机有內置的加密经济学激励机制,鼓励预言机节点报告正确的信息,使它们比中心化预言机更安全。

如果你打算通过查询链上预言机获得资产价格,考虑使用实施了时间加权平均价格 (TWAP) 机制的预言机。 时间加权平均价格预言机opens in a new tab查询资产在两个不同时间点(可以修改)的价格,并计算出基于所得平均值的现货价格。 选择较长的时间段可以保护协议免受价格操纵,因为最近执行的大宗订单无法影响资产价格。

面向开发者的智能合约安全性资源

用于分析智能合约和验证代码正确性的工具

智能合约监测工具

智能合约的安全管理工具

智能合约审计服务

漏洞奖励平台

已知智能合约漏洞及利用情况的刊物

智能合约安全学习难点

确保智能合约安全的最佳做法

智能合约安全性教程