Solidity存储布局的冲突

  • mixbytes
  • 发布于 2020-02-25 10:31
  • 阅读 30

本篇文章是关于可升级智能合约的系列文章中的第二篇,深入探讨了Solidity的数据存储方法及使用代理合约的潜在问题。文章重点讲解了以太坊虚拟机(EVM)的存储模型,以及如何避免不同版本智能合约之间存储布局的碰撞,提供了一些最佳实践和解决方案,具有较高的技术深度和实用价值。

作者: MixBytes 团队

image.png

这是系列文章“可升级智能合约:存储亮点与挑战”的第二篇。第一部分可以在 这里 找到。

这次我们将更深入地探讨 Solidity 数据存储方法及使用代理合约的潜在陷阱。

Delegatecall 和 Solidity 存储布局的冲突

让我提醒你,EVM 存储模型是什么样的,以及 Solidity 如何应用它来存储基本类型变量、数组和映射。

以太坊智能合约存储为 uint256 到 uint256 的映射。Uint256 值是 32 字节宽;这个固定大小的值在以太坊上下文中被称为 槽位。该模型类似于虚拟随机存取存储器,但有一个例外,即地址宽度为 256 位(与标准的 32 和 64 位不同),每个值的大小是 32 字节而不是 1。256 位宽的地址为一个众所周知的 Solidity 技巧留出了足够的空间:任何 256 位的哈希都可以作为地址,我们稍后会回到这些问题。 这种数据存储方法相当独特,并且与适用于 WebAssembly 的方法有所不同,但它的有效性超出了本文的范围。

在标准计算机程序执行期间,必须控制 RAM,以确保不同的变量和数据结构不会“冲突”并损坏彼此的数据。通常,所谓的内存分配器会完成这个任务。分配器有一个 API(malloc、free、new、delete 和其他函数)。此外,记录通常以“紧凑”的方式存储,以免在地址空间中留下过多的数据,这也是分配器的职责。Solidity 不具备控制存储的分配器,因此任务以其他方式处理。智能合约值存储在槽位中,从槽位 0 开始。基本固定大小值类型占用一个槽位。此外,它们有时可以打包到一个槽位中并动态解包。为了存储数组,Solidity 会将数组元素计数记录到下一个槽位(我们称之为“头槽”)。元素本身将位于可以计算为数组头槽编号的 keccak256 哈希的地址上。这类似于 C++ 和 Java 中使用的动态数组存储机制,其中数组数据结构位于一个独立的内存位置,由主结构进行引用。唯一的不同是,Solidity 并不会在任何地方保留此指针。这是可能的,因为我们可以在任何存储位置写入而无需分配内存——它完全属于我们,并默认初始化为零值。

例如,在以下代码中调用 allocate() 函数后:

uint256 foo;
uint256 bar;
uint256[] items;

function allocate() public {
    require(0 == items.length);

    items.length = 2;
    items[0] = 12;
    items[1] = 42;
}

存储看起来像:

我们可以通过执行 js 代码轻松检查数组元素地址:

web3.sha3('0x00000000000000000000000000000000000000000000000000000000000000002
', {encoding: 'hex'})

映射具有类似的机制,唯一不同的是每个值单独存放,哈希计算涉及相应的键。你可能会想到可能的数据冲突,但 这些 冲突被忽略,就像 256 字节哈希冲突一样。合约继承在当前情况下并没有增加复杂性。对于使用继承的合约,状态变量的顺序由从最基础合约开始的 C3 线性化顺序确定。

上述规则称为“存储中的状态变量布局”(后文称为“存储布局”),详细信息可以在 这里 找到并应进行检查。修改这些规则将消除向后兼容性,因此我们不太可能在未来看到任何影响智能合约和库的更改。

现在我们已经熟悉了代理智能合约的操作和存储布局,让我们了解可能出现的错误。

特定的代码版本,将数据记录在代理的存储中,它有自己的变量和存储布局。以下版本也将具有自己的存储布局,并必须能够处理根据前一个存储布局形成的数据。这是问题的一半。不要忘记代理代码也有一个存储布局,并且与当前获得控制权的智能合约版本并行运行。因此,代理代码存储布局和当前智能合约版本不应交互,即它们不能为不同的数据使用相同的槽位。

最简单的解决方案是在不使用常规 Solidity 存储布局机制的情况下存储代理数据,使用 EVM 的 sstore 和 sload 指令来读取和写入伪随机数字槽位中的数据,例如,通过以下哈希函数返回的 keccak256(my.proxy.version)。我们成功避免了冲突。

另一种方法是使用相同的存储布局和高级数据争议解决,如 github 中所示。

让我们想一想,如果我们错过两个“竞争”存储布局会发生什么。看看来自 AkropolisToken 仓库 的 commit 3ad8eaa6f2849dceb125c8c614d5d61e90d465a2。我们为那家公司执行了代币销售合约的安全审计。

我们注意到 TokenProxy 和当前的 AkropolisToken 版本都有自己的状态变量(AkropolisToken 的状态变量位于 basic contracts)。我们预计会发生冲突,因此很可能会面临麻烦。然而,快速测试使这个猜想失效。如果我们在调用 pause 函数后更改暂停标志(该标志存在于 AkropolisToken 中从 Pausable 继承),TokenProxy 的状态变量将不会更改。TokenProxy 函数调用执行正确——调用 TokenProxy 合约的 getter 函数是在按代币地址定义的之后发生的。由于代理没有暂停函数,因此它是通过调用基本 UpgradeabilityProxy 合约的默认函数来调用的,该函数又在包含 pause 函数的 AkropolisToken 中执行 delegatecall。为什么仍然没有冲突呢?

我们必须集中精力,详细绘制 TokenProxy 和 AkropolisToken 的槽位图,遵循上述状态变量位置规则。我们必须找出基本合约的正确顺序,并考虑将几个状态变量打包到一个槽的可能性。你可以在 Remix 中测试你的结果:发送记录的交易,调试它并跟踪存储变化。

结果如下:

请注意槽位 3 和 4。在 TokenProxy 合约中,槽位 3 用于存储 pendingOwner 变量。然而,pendingOwner 属于地址类型,仅为 20 字节宽,即它并未占用整个槽位。因此,一位布尔标志 paused 和 locked 也可以打包到该槽位,这反过来又解释了 paused 和 name 标志之间没有冲突。由于槽位 4,即白名单映射中的头槽未被使用,因此没有 name 和 whitelist 之间的冲突。

这两个合约几乎逃脱了冲突,但我们仍然可以追踪到槽位 5。为了说明我们的观点,我们编写了以下测试:

https://gist.github.com/Eenae/8e9affde78e2e15dfd6e75174eb2880a

该测试在第 42 行失败——decimals 值不再等于 18,尽管由于 TokenProxy 合约代码,该值是不可更改的。

最简单的解决方案是禁用槽位 5,这在 这个 commit 中执行了。

结论

使用低级指令,如 delegatecall,需要对 Solidity 存储布局有深入了解。我们简单回顾了这个主题,提供了可能的问题示例,并提出了几种解决方案。祝你合约安全!

  • 谁是 MixBytes?

MixBytes 是一个区块链审计和安全研究的专家团队,专注于为 EVM 兼容和基于 Substrate 的项目提供全面的智能合约审计和技术咨询服务。请加入我们的 X,与我们一起了解最新的行业趋势和见解。

  • 原文链接: mixbytes.io/blog/collisi...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
mixbytes
mixbytes
Empowering Web3 businesses to build hack-resistant projects.