合约升级反模式 - Trail of Bits博客
作者:Josselin Feist
发布日期:2018年9月5日
标签:攻击,区块链
智能合约设计中的一个流行趋势是推动可升级合约的开发。在Trail of Bits,我们审查了许多可升级合约,并认为这一趋势正朝着错误的方向发展。现有的合约升级技术存在缺陷,显著增加了合约的复杂性,并最终引入了错误。为了强调这一点,我们披露了Zeppelin合约升级策略中一个此前未知的缺陷,这是最常见的升级方法之一。
在本文中,我们将详细分析现有的智能合约升级策略,描述我们在实践中观察到的弱点,并为需要升级的合约提供建议。在后续的博客文章中,我们将详细介绍一种方法——合约迁移,它以较少的缺点实现相同的益处。
可升级合约概述
可升级智能合约出现了两种模式“家族”:
- 数据分离:逻辑和数据保存在单独的合约中。逻辑合约拥有并调用数据合约。
- 基于delegatecall的代理:逻辑和数据也保存在单独的合约中,但数据合约(代理)通过delegatecall调用逻辑合约。
数据分离模式具有简单性的优势。它不需要像delegatecall模式那样的低级专业知识。delegatecall模式最近受到了很多关注。开发者可能倾向于选择这种解决方案,因为文档和示例更容易找到。
使用这两种模式都伴随着相当大的风险,这是这一趋势中迄今为止未被承认的一个方面。
数据分离模式
数据分离模式将逻辑和数据保存在单独的合约中。逻辑合约拥有数据合约,可以在需要时升级。数据合约不打算升级。只有所有者可以更改其内容。
图1:数据分离升级模式的高级概述
在考虑这种模式时,要特别注意这两个方面:如何存储数据,以及如何执行升级。
数据存储策略
如果升级所需的变量保持不变,你可以使用一个简单的设计,其中数据合约持有这些变量及其getter和setter。只有合约所有者应该能够调用setter:
|
|
图2:数据存储示例(使用onlyOwner修饰符)
你必须清楚识别所需的状态变量。这种方法适用于基于ERC20代币的合约,因为它们只需要存储其余额。
如果未来的升级需要新的持久变量,它们可以存储在第二个数据合约中。你可以将数据拆分到单独的合约中,但代价是额外的逻辑合约调用和授权。如果你不打算频繁升级合约,额外的成本可能是可以接受的。
没有什么可以阻止向逻辑合约添加状态变量。这些变量在升级期间不会被保留,但对于实现逻辑可能有用。如果你想保留它们,也可以将它们迁移到新的逻辑合约中。
键值对
键值对系统是上述简单数据存储解决方案的替代方案。它更易于演变,但也更复杂。例如,你可以声明一个从bytes32键值到每个基本变量类型的映射:
|
|
图3:键值存储示例(使用onlyOwner修饰符)
这种解决方案通常被称为永恒存储模式。
如何执行升级
这种模式提供了几种不同的策略,具体取决于数据的存储方式。
最简单的方法之一是将数据合约的所有权转移到一个新的逻辑合约,然后禁用原始逻辑合约。要禁用先前的逻辑合约,实现一个可暂停机制或在数据合约中将其指针设置为0x0。
图4:通过部署新的逻辑合约并禁用旧合约来升级
另一种解决方案涉及将调用从原始逻辑合约转发到新版本:
图5:通过部署新的逻辑合约并从旧合约转发调用来升级
如果你希望允许用户调用第一个合约,这种解决方案很有用。然而,它增加了复杂性;你必须维护更多合约。
最后,一种更复杂的方法使用第三个合约作为入口点,具有可更改的逻辑合约指针:
图6:通过部署代理合约调用新的逻辑合约来升级
代理合约为用户提供了一个恒定的入口点,并且比转发解决方案更清晰地区分了职责。然而,它带来了额外的gas成本。
Cardstack和Rocket-pool有数据分离模式的详细实现。
数据分离模式的风险
数据分离模式的简单性更多是感知上的而非真实的。这种模式增加了代码的复杂性,并需要更复杂的授权模式。我们多次看到客户错误地部署这种模式。例如,一个客户的实现达到了相反的效果,其中某个功能无法升级,因为其部分逻辑位于数据合约中。
根据我们的经验,开发者也发现永恒存储模式难以一致应用。我们见过开发者将值存储为bytes32,然后应用类型转换来检索原始值。这增加了数据模型的复杂性,以及出现细微缺陷的可能性。不熟悉复杂数据结构的开发者会在此模式上犯错。
基于delegatecall的代理模式
与数据分离方法类似,代理模式将一个合约分为两部分:一个合约持有逻辑,一个代理合约持有数据。有什么不同?在这种模式中,代理合约使用delegatecall调用逻辑合约;顺序相反。
图7:代理模式的可视化表示
在这种模式中,用户与代理交互。持有逻辑的合约可以更新。这种解决方案需要掌握delegatecall,以允许一个合约使用另一个合约的代码。
让我们回顾一下delegatecall的工作原理。
delegatecall背景
delegatecall允许一个合约执行另一个合约的代码,同时保持调用者的上下文,包括其存储。delegatecall操作码的一个典型用例是实现库。例如:
|
|
图8:基于delegatecall操作码的库示例
这里,将部署两个合约:Lib和C。在C中对Lib的调用将通过delegatecall完成:
图9:调用Lib.set的EVM操作码(Ethersplay输出)
因此,当Lib.set更改self.val时,它更改了存储在C的myVal变量中的值。
Solidity看起来像Java或JavaScript,它们是面向对象的语言。它很熟悉,但带有误解和假设的包袱。在以下示例中,程序员可能假设只要两个合约变量共享相同的名称,它们就会共享相同的存储,但Solidity并非如此。
|
|
图10:危险的delegatecall用法
图11表示部署时两个合约的代码和存储变量:
图11:图10的内存图示
当delegatecall执行时会发生什么?LogicContract.set将写入ProxyContract.contract_pointer而不是ProxyContract.a。这种内存损坏发生是因为:
- LogicContract.set在ProxyContract的上下文中执行。
- LogicContract只知道一个状态变量:a。任何对此变量的存储将在内存中的第一个元素上完成(参见存储中状态变量的布局文档)。
- ProxyContract的第一个元素是contract_pointer。因此,LogicContract.set将写入ProxyContract.contract_pointer变量而不是ProxyContract.a(参见图12)。
此时,ProxyContract中的内存已损坏。
如果a是ProxyContract中声明的第一个变量,delegatecall就不会损坏内存。
图12:LogicContract.set将写入存储中的第一个元素:ProxyContract.contract_pointer
谨慎使用delegatecall,特别是在被调用合约声明了状态变量的情况下。
让我们回顾基于delegatecall的不同数据存储策略。
数据存储策略
在使用代理模式时,有三种方法分离数据和逻辑:
- 继承存储:使用Solidity继承来确保调用者和被调用者具有相同的内存布局。
- 永恒存储:这是我们上面看到的逻辑分离的键值存储版本。
- 非结构化存储:这是唯一一种不会因错误的内存布局而遭受潜在内存损坏的策略。它依赖于内联汇编代码和存储变量的自定义内存管理。
有关这些方法的更全面审查,请参见ZeppelinOS。
如何执行升级
要升级代码,代理合约需要指向一个新的逻辑合约。先前的逻辑合约随后被丢弃。
delegatecall的风险
根据我们与客户的经验,我们发现很难正确应用基于delegatecall的代理模式。代理模式要求内存布局在合约和编译器升级之间保持一致。不熟悉EVM内部的开发者很容易在升级过程中引入关键错误。
只有一种方法,非结构化存储,克服了内存布局要求,但它需要低级内存处理,这难以实现和审查。由于其高复杂性,非结构化存储仅用于存储对合约可升级性关键的状态变量,例如指向逻辑合约的指针。此外,这种方法阻碍了Solidity的静态分析(例如,通过Slither),使合约失去了这些工具提供的保证。
通过自动化工具防止内存布局损坏是一个持续的研究领域。没有现有工具可以验证升级是否安全免受损害。使用delegatecall的升级将缺乏自动安全保证。
打破代理模式
确切地说,我们发现并现在披露了Zeppelin代理模式中一个此前未知的安全问题,其根源在于delegatecall的复杂语义。它影响我们调查的所有Zeppelin实现。这个问题突出了使用低级Solidity机制的复杂性,并说明了实现这种模式可能出现缺陷的可能性。
错误是什么?
Zeppelin代理合约在返回之前不检查合约的存在。因此,代理合约可能对失败的调用返回成功,并且如果调用结果需要用于应用程序逻辑,可能导致不正确行为。
低级调用,包括汇编,缺乏高级Solidity调用提供的保护。特别是,低级调用不会检查被调用账户是否有代码。Solidity文档警告:
低级调用、delegatecall和callcode将返回成功,如果被调用账户不存在,这是EVM设计的一部分。如果需要,必须在调用之前检查存在性。
如果delegatecall的目标没有代码,那么调用将成功返回。如果代理设置不正确,或者目标被销毁,任何对代理的调用都会成功但不会返回数据。
调用代理的合约可能会改变自己的状态,假设其交互是成功的,即使它们不是。
如果调用者不检查返回数据的大小(这是任何用Solidity 0.4.22或更早版本编译的合约的情况),那么任何调用都会成功。对于最近编译的合约(Solidity 0.4.24及以上),由于对returndatasize的检查,情况稍好。然而,该检查不会保护不期望返回数据的调用。
ERC20代币面临相当大的风险
许多ERC20代币有一个已知的缺陷,阻止传输函数返回数据。因此,这些合约支持可能不返回数据的传输调用。在这种情况下,如上所述,缺乏存在检查可能导致第三方相信代币传输成功而实际上没有,并可能导致资金被盗。
利用场景
Bob的ERC20智能合约是一个基于delegatecall的代理合约。由于人为错误、代码缺陷或恶意行为者,代理设置不正确。任何对代币的调用都将表现为成功的调用,没有返回数据。
Alice的交易所处理在传输时不返回数据的ERC20代币。Eve没有代币。Eve调用Alice交易所的存款函数获取10,000代币,该函数调用Bob代币的transferFrom。调用成功。Alice的交易所将10,000代币记入Eve账户。Eve出售代币并免费获得以太币。
如何避免此缺陷
在升级期间,检查新的逻辑合约是否有代码。一种解决方案是使用extcodesize操作码。或者,你可以在每次使用delegatecall时检查目标的存在。
有工具可以提供帮助。例如,Manticore能够审查你的智能合约代码,以在对其进