智能合约升级反模式:深入解析数据分离与代理模式的风险

本文详细分析了智能合约升级的两种常见模式——数据分离和基于delegatecall的代理模式,揭示了它们的内存布局风险、实现复杂性和潜在安全漏洞,并提供了实用的安全建议和替代方案。

合约升级反模式 - Trail of Bits 博客

作者:Josselin Feist
发布日期:2018年9月5日
标签:攻击,区块链

在智能合约设计中,一个流行的趋势是推广可升级合约的开发。在 Trail of Bits,我们审查了许多可升级合约,并认为这一趋势正朝着错误的方向发展。现有的合约升级技术存在缺陷,显著增加了合约的复杂性,并最终引入了错误。为了强调这一点,我们披露了 Zeppelin 合约升级策略中一个先前未知的缺陷,这是最常见的升级方法之一。

在本文中,我们将详细分析现有的智能合约升级策略,描述我们在实践中观察到的弱点,并为需要升级的合约提供建议。在后续的博客文章中,我们将详细介绍一种方法——合约迁移,该方法实现了相同的益处,但几乎没有缺点。

可升级合约概述

可升级智能合约出现了两种“家族”模式:

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

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

使用这两种模式都伴随着相当大的风险,这是这一趋势至今未被承认的一个方面。

数据分离模式

数据分离模式将逻辑和数据保存在单独的合约中。逻辑合约拥有数据合约,可以在需要时升级。数据合约不打算升级。只有所有者可以更改其内容。

图1:数据分离升级模式的高级概述

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

数据存储策略

如果升级所需的变量保持不变,可以使用一个简单的设计,其中数据合约保存这些变量及其 getter 和 setter。只有合约所有者应该能够调用 setter:

1
2
3
4
5
6
7
contract DataContract is Owner {
  uint public myVar;

  function setMyVar(uint new_value) onlyOwner public {
    myVar = new_value;
  }
}

图2:数据存储示例(使用 onlyOwner 修饰符)

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

如果未来的升级需要新的持久变量,它们可以存储在第二个数据合约中。您可以将数据拆分到单独的合约中,但代价是额外的逻辑合约调用和授权。如果您不打算频繁升级合约,额外的成本可能是可以接受的。

没有什么可以阻止向逻辑合约添加状态变量。这些变量在升级期间不会被保留,但对于实现逻辑可能有用。如果您想保留它们,也可以将它们迁移到新的逻辑合约中。

键值对

键值对系统是上述简单数据存储解决方案的替代方案。它更易于演进,但也更复杂。例如,您可以声明一个从 bytes32 键值到每个基本变量类型的映射:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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 修饰符)

这种解决方案通常被称为 Eternal Storage 模式。

如何执行升级

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

最简单的方法之一是将数据合约的所有权转移到一个新的逻辑合约,然后禁用原始逻辑合约。要禁用先前的逻辑合约,实现一个可暂停机制或在数据合约中将其指针设置为 0x0。

图4:通过部署新的逻辑合约并禁用旧合约来升级

另一种解决方案涉及将调用从原始逻辑合约转发到新版本:

图5:通过部署新的逻辑合约并从旧合约转发调用来升级

如果您想允许用户调用第一个合约,这种解决方案很有用。然而,它增加了复杂性;您必须维护更多合约。

最后,一种更复杂的方法使用第三个合约作为入口点,具有可更改的逻辑合约指针:

图6:通过部署代理合约调用新的逻辑合约来升级

代理合约为用户提供了一个恒定的入口点,并且比转发解决方案更清晰地区分了职责。然而,它带来了额外的 gas 成本。

Cardstack 和 Rocket-pool 有数据分离模式的详细实现。

数据分离模式的风险

数据分离模式的简单性更多是感知上的而非真实的。这种模式增加了代码的复杂性,并需要更复杂的授权模式。我们多次看到客户错误地部署了这种模式。例如,一个客户的实现达到了相反的效果,其中某个功能无法升级,因为其部分逻辑位于数据合约中。

根据我们的经验,开发人员也发现 EternalStorage 模式难以一致应用。我们见过开发人员将值存储为 bytes32,然后应用类型转换来检索原始值。这增加了数据模型的复杂性,以及细微缺陷的可能性。不熟悉复杂数据结构的开发人员会在此模式上犯错。

基于 delegatecall 的代理模式

与数据分离方法类似,代理模式将一个合约分成两个:一个合约保存逻辑,一个代理合约保存数据。有什么不同?在这种模式中,代理合约使用 delegatecall 调用逻辑合约;顺序相反。

图7:代理模式的可视化表示

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

让我们回顾一下 delegatecall 的工作原理。

delegatecall 的背景

delegatecall 允许一个合约执行另一个合约的代码,同时保持调用者的上下文,包括其存储。delegatecall 操作码的一个典型用例是实现库。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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 并非如此。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 {
    // 注意:应检查 delegatecall 的返回值
    contract_pointer.delegatecall(bytes4(keccak256("set(uint256)")), val);
  }
}

图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 能够审查您的智能合约代码,以在对其进行任何调用之前检查合约的存在。此检查旨在帮助减轻有风险的代理合约升级。

建议

如果您必须设计智能合约升级解决方案,请使用适合您情况的最简单解决方案。

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

数据分离建议

如果您需要存储数据,选择简单的数据存储策略而不是键对(又称 Eternal Storage)。这种方法需要编写更少的代码,并依赖更少的移动部件。简单来说,出错的可能性更小。

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

代理模式建议

在调用 delegatecall 之前检查目标合约的存在。Solidity 不会为您执行此检查。忽略检查可能导致意外行为和安全问题。如果您依赖低级功能,您有责任进行这些检查。

如果您使用代理模式,您必须:

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

结论

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

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

在后续的博客文章中,我们将描述合约迁移,这是我们推荐的方法,以实现可升级智能合约的益处而没有其缺点。在私钥泄露的情况下,合约迁移策略至关重要,并有助于避免其他升级的需要。

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

如果您喜欢这篇文章,请分享: Twitter LinkedIn GitHub Mastodon Hacker News

页面内容

  • 可升级合约概述
  • 数据分离模式
  • 数据存储策略
  • 如何执行升级
  • 数据分离模式的风险
  • 基于 delegatecall 的代理模式
  • delegatecall 的背景
  • 数据存储策略
  • 如何执行升级
  • delegatecall 的风险
  • 打破代理模式
  • 错误是什么?
  • ERC20 代币面临相当大的风险
  • 利用场景
  • 如何避免此缺陷
  • 建议
  • 数据分离建议
  • 代理模式建议
  • 结论

最近文章

  • 非传统创新者奖学金
  • 在您的 PajaMAS 中劫持多代理系统
  • 我们构建了 MCP 一直需要的安全层
  • 利用废弃硬件中的零日漏洞
  • 在 EthCC[8] 内部:成为智能合约审计员

© 2025 Trail of Bits。 使用 Hugo 和 Mainroad 主题生成。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计