2

合约升级模式分析

 2 years ago
source link: https://learnblockchain.cn/article/3308
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

在这篇文章中,我们将详细分析现有的智能合约升级策略,描述我们在实践中观察到的弱点,并为需要升级的合约提供建议。

对两种合约升级模式的分析

编者注: 原文发表于 2018 年 9 月,当时没有成熟的库或工具来实现升级。现在有了 upgrades-plugins 让升级变得简单很多。
不过文中提到的很多关于合约升级的问题,在今天来看依旧非常有价值,也是社区选择翻译它的原因。

智能合约设计的一个流行趋势是促进可升级合约的发展。在 Trail of Bits ,我们已经审查了很多可升级合约,并认为这一趋势的方向是错误的。现有的升级合约的技术有缺陷,大大增加了合约的复杂性,容易产生 bug 。为了强调这一点,我们发布了 Zeppelin 合约升级策略中一个以前不为人知的缺陷,这也是最常见的升级方法之一。

在这篇文章中,我们将详细分析现有的智能合约升级策略,描述我们在实践中观察到的弱点,并为需要升级的合约提供建议。在后续的博文中,我们将详细介绍一种方法,即合约迁移,它可以实现同样的好处,而少有缺点。

可升级合约的概述

可升级的智能合约已经出现了两种模式:

数据分离:逻辑和数据被保存在不同的合约中。逻辑合约拥有并调用数据合约。
基于delegatecall的代理,逻辑和数据也被保存在不同的合约中,但数据合约(代理)通过 delegatecall 调用逻辑合约。

数据分离模式的优点是简单。它不需要像 delegatecall 模式那样的底层的专业知识。不过,最近delegatecall 模式受到了广泛的关注。开发人员可能倾向于选择这种解决方案,因为文档和例子更容易找到。

这两种模式都有相当大的风险,但在趋势之下,这种风险并未被重视。

数据分离模式

数据分离模式将逻辑和数据保持在不同的合约中。拥有数据合约的逻辑合约可以在需要时进行升级。数据合约是不可以升级的。只有所有者可以改变其内容。

图1:数据分离升级模式

在考虑这种模式时,要特别注意这两个方面:如何存储数据,以及如何进行升级。

数据存储策略

如果在整个升级过程中需要的变量保持不变,你可以使用一个简单的设计,即让数据合约持有这些变量,以及它们的getterssetters。只有合约所有者能够调用setters

contract DataContract is Owner {
  uint public myVar;
 
  function setMyVar(uint new_value) onlyOwner public {
    myVar = new_value;
  }
}

图2:数据存储实例

你必须清楚地确定所需的状态变量。这种方法适用于基于 ERC20 代币的合约,因为它们只需要存储余额。

如果未来的升级需要新的存储变量,它们可以被存储在第二个数据合约中。你可以在不同的合约中分割数据,但代价是额外的逻辑合约调用和授权。如果你不打算经常升级合约,额外的成本可能是可以接受的。

这并不阻止将状态变量添加到逻辑合约中。这些变量在升级过程中不会被保留,但对于实现逻辑来说是有用的。如果你想保留它们,你也可以把它们迁移到新的逻辑合约中。

key-value pair

键值对系统是上述简单数据存储方案的一个替代方案。它更适应变化,但也更复杂。如你可以声明一个从bytes32键值到每个基本变量类型的映射:

contract DataContract is Owner {
  mapping(bytes32 => uint) uIntStorage;
 
  function getUint(bytes32 key) view public returns(uint) {
    return uintStorage[key];
}
 
  function setUint(bytes32 key, uint new_val) onlyOwner public {
    uintStorage[key] = new_val;
  }
}

图3:键值存储示例(使用 onlyOwner )。

这种解决方案通常被称为永恒存储模式。

如何进行升级

这种模式提供了几种不同的策略,取决于数据的存储方式。

最简单的方法之一是将数据合约的所有权转移到一个新的逻辑合约,然后禁用原来的逻辑合约。要禁用之前的逻辑合约,需要将旧的逻辑合约暂停或者将旧的逻辑合约指向数据合约的0X0 。

图4:通过部署新的逻辑合约和禁用旧的逻辑合约进行升级

另一个解决方案是将旧逻辑合约的调用转移到新逻辑合约。

图5:部署一个新逻辑合约并从旧逻辑合约调用转移到新的逻辑合约

如果你只想让用户调用第一个合约,这个解决方案是有用的。然而,它增加了复杂性;你必须维护更多的合约。

最后,一个更复杂的方法,用第三个合约作为一个切入点,它由一个可变指针来指向逻辑合约。

图6:部署一个代理合约来调用新的逻辑合约

代理合约为用户提供了一个固定的入口,并对责任进行了区分,比调用转移方案更清晰。然而,它也带来了额外的Gas成本。

CardstackRocket-pool有数据分离模式的详细实现。

数据分离模式的风险

数据分离模式看似简单,但其实不是这样的。这种模式增加了代码的复杂性,并且需要更复杂的授权模式。我们已经很多次看到客户错误地部署了这种模式。例如,有一个客户还实现了相反的效果,根本无法升级,因为他的一些逻辑是在数据合约中的。

根据我们的经验,开发者也知道永恒存储模式非常具有挑战。我们看到开发者将值存储为bytes32,然后用类型转换来获取原来的值。这增加了数据模型的复杂性,以及出现细微缺陷的可能性,不熟悉复杂数据结构的开发者可能会容易犯错。

基于delegatecall的代理模式

像数据分离模式一样,代理模式将合约一分为二:一个逻辑合约,一个代理合约(持有数据)。有什么不同?在这种模式中,代理合约用delegatecall调用逻辑合约(与之前调用次序相反)。

图7:代理模式

在这种模式下,用户与代理合约交互,合约逻辑是可以升级。这个解决方案需要掌握delegatecall,以允许一个合约使用另一个合约的代码。

让我们回顾一下delegatecall是如何工作的。

delegatecall背景知识

delegatecall允许一个合约执行另一个合约的代码,同时保持调用者的上下文(包括存储)不会变。delegatecall操作码的一个典型使用场景是实现library。如:

pragma solidity ^0.4.24;
 
library Lib {
 
  struct Data { uint val; }
 
  function set(Data storage self, uint new_val) public {
    self.val = new_val;
  }
}
 
contract C {
  Lib.Data public myVal;
 
  function set(uint new_val) public {
    Lib.set(myVal, new_val);
  }
}

图8:基于 delegatecall 操作码的库实例

这里,将部署两个合约: Lib 和 C。在 C 中对 Lib 的调用将通过delegatecall完成。

图9:调用 Lib.set 的 EVM 操作码(Ethersplay)

因此,当Lib.set改变self.val时,它改变了存储在 C 的myVal变量中的值。

Solidity 看起来像 Java 或 JavaScript ,它们都是面向对象的语言。你会觉得它很熟悉,但因此也会对它产生一些错误的认识和假设。在下面的例子中,程序员可能会认为,只要两个合约变量共享相同的名字,那么它们就会共享相同的存储空间,但在 Solidity 中并不是这样的。

pragma solidity ^0.4.24;
 
contract LogicContract {
  uint public a;
 
  function set(uint val) public {
    a = val;
  }
}
 
contract ProxyContract {
  address public contract_pointer;
  uint public a;
 
  constructor() public {
    contract_pointer = address(new LogicContract());
  }
 
  function set(uint val) public {
    // Note: the return value of delegatecall should be checked
    contract_pointer.delegatecall(bytes4(keccak256("set(uint256)")), val);
  }
}

图10:危险的委托调用用法

下面图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的代理模式是很难的。代理模式需要升级过程中合约和编译器始终保持一致。开发者如果不熟悉 EVM 内部结构很容易在升级过程中引入严重错误。

只有一种方案,即非结构化存储,它不受内存布局要求的限制,但它要求低级别的内存处理,这很难实现和也不利于审查。由于它极其复杂,非结构化存储只用于存储对可升级合约非常重要的状态变量,比如指向逻辑合约的指针。此外,这种方案阻碍了 Solidity 的静态分析(如Slither),使合约失去了这些工具提供的保证。

用自动化工具防止内存布局损坏是一个正在进行的研究领域。当前没有任何现有的工具可以验证升级是安全的,不损害内存接口。使用 delegatecall 的升级将缺乏自动化的安全保证。

打破了代理模式

我们发现并公布了 Zeppelin 代理模式中一个以前未知的安全问题,其根源在于delegatecall的复杂语义。它影响了我们所调查的所有 Zeppelin 实现。这个问题突出了使用低级 Solidity 机制的复杂性,并说明了这种模式的实现有可能存在缺陷。

是什么Bug?

Zeppelin 代理合约在返回之前并不检查合约是否存在。因此,代理合约可能在调用失败时返回成功,如果调用结果是应用程序逻辑所需要的,则会导致错误的行为。

低级别调用,包括汇编,缺乏高级别 Solidity 调用所提供的保护。特别是,低级别调用不会检查被调用账户是否有代码。Solidity 文档警告说:

如果被调用的账户不存在,作为EVM设计的一部分,低级别调用,delegatecall和调用代码将返回成功,如果需要,必须在调用前检查是否存在。

如果delegatecall的目标没有代码,那么调用将成功返回。如果代理设置不正确,或者目标被破坏,任何对代理的调用都会成功,但不会发回数据。

调用代理的合约可能会在假设其交互成功的情况下改变自己的状态,尽管它们并不成功。

如果调用者不检查返回的数据的大小,这是任何用 Solidity 0.4.22 或更早编译的合约的情况,那么任何调用都会成功。由于对 returndatasize 的检查,最近编译的合约(Solidity 0.4.24及以上版本)情况稍好。然而,这个检查并不能保护那些不期望有数据返回的调用。

ERC20 token 面临相当大的风险

许多ERC20 token 有一个已知的缺陷,transfer函数没有返回数据。因此,这些合约支持对transfer()的调用,可能没有返回数据。在这种情况下,缺乏上文详述的存在性检查,可能会导致第三方认为代币转移是成功的,而事实并非如此,并可能导致资金被盗。

Bob 的 ERC20 智能合约是一个基于delegatecall的代理合约。由于人为错误(代码中的缺陷或恶意行为),代理被错误地设置。任何对 token 的调用都被认为是成功的调用,没有数据返回。

Alice 交易所处理 ERC20 token ,在转账时不返回数据 。Eve 没有 token ,Eve 调用 Alice 交易所的存款函数,以获得 10,000 个 token ,该函数调用 Bob 的 token 的transferFrom,这个调用是成功的。Alice 交易所将 10,000 个 token 记入 Eve 的账户,Eve 卖出 token ,并免费获得了以太币。

在升级过程中,检查新的逻辑合约是否有代码。一个解决方案是使用 extcodesize 操作码。另外,你可以在每次使用 delegatecall 时检查目标是否存在。

有一些工具可以提供帮助。例如,Manticore 能够审查你的智能合约代码,以便在对其进行任何调用之前检查合约是否存在。这种检查是为了帮助减轻有风险的代理合约升级。

编者注:在进行转账时,应该总是使用 TansferHelper 的 safeTransfer

如果你必须设计一个智能合约的升级方案,请根据你的情况使用最简单的方案。

在所有情况下,避免使用内联汇编和低级调用。正确使用这一功能需要对 delegatecall 的语义以及 Solidity 和 EVM 的内部结构非常熟悉。在我们审查的代码中,很少有团队能做到这一点。

数据分离模式建议

如果你需要存储数据,请选择简单的数据存储策略,而不是键-值对(又称永恒存储)。这种方法需要编写的代码较少,并且依赖的会变化的部分相对较少,可能出错的地方相对较少。

使用丢弃合约的解决方案来执行升级,避免使用调用转移的解决方案,因为它需要建立转发逻辑,可能太复杂而无法正确实现。只有在你需要一个固定地址时才使用代理解决方案。

代理模式建议

在调用 delegatecall 之前,检查目标合约是否存在。Solidity 不会代表你执行检查,但忽视检查可能导致非预期的行为和安全问题。如果依赖低级别的功能,你需要负责这些检查。

如果你正在使用代理模式,你必须:

对以太坊的内部有详细的了解,包括 delegatecall 的精确机制以及 Solidity 和 EVM 内部的详细知识。
认真考虑继承的顺序,因为它影响内存布局。
认真考虑变量的声明顺序,例如,变量的覆盖,甚至类型的改变(如下所述)都会影响程序员与delegatecall交互时的意图。
注意编译器可能会使用填充和/或将变量打包在一起,例如,如果将两个连续的 uint256 改为两个 uint8,编译器可以将这两个变量存储在一个槽中,而不是两个。
确认变量的内存布局是否得到重视,如果使用不同版本的 solc 或者启用不同的优化功能。不同版本的 solc 计算存储偏移的方式不同。变量的存储顺序可能会影响 Gas 成本、内存布局,从而影响delegatecall的结果。
仔细考虑合约的初始化,根据代理变体,状态变量在构建过程中可能无法初始化。因此,在初始化过程中存在一个潜在的竞赛条件,需要加以缓解。
仔细考虑代理中的函数名称,以避免函数名称碰撞。与预定函数具有相同Keccak 哈希值的代理函数将被调用,这可能导致不可预测的或恶意的行为。

结论性意见

我们强烈建议不要对可升级的智能合约使用这些模式。这两种策略都有可能出现缺陷,大大增加复杂性,并引入错误,最终降低对你的智能合约的信任。努力追求简单、不可变和安全的合约,而不是导入大量的代码来推迟功能和安全问题。

此外,审查智能合约的安全工程师不应推荐复杂的、不甚了解的、可能不安全的升级机制。以太坊安全社区,在认可这些技术之前要考虑风险。

在后续的博文中,我们将描述合约迁移,这是我们推荐的可以实现和升级合约一样的效果的方法,同时没有升级带来的缺点。在私钥被破坏的情况下,合约迁移策略是必不可少的,并有助于避免其他升级的需要。

同时,如果你担心你的升级策略可能不安全,你应该联系我们


本翻译由 CellETF 赞助支持。

本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

  • 发表于 1天前
  • 阅读 ( 273 )
  • 学分 ( 14 )
  • 分类:智能合约

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK