2023 年 7 月 4 日,OtterSec 的 Robert Chen 发现了 Yul 优化器中的一个错误。
编译器最早受影响的版本是 0.6.7,该版本引入了修改优化器步骤顺序的功能。Solidity 版本 0.8.21,于 2023 年 7 月 19 日发布,提供了修复。
我们为该错误分配了“低”的总体评分。该错误在受影响的情况下具有“高”严重性,但我们认为它实际影响已部署合约的可能性“非常低”。
哪些合约受到影响?
触发该错误的先决条件是满足以下**所有**条件
-
使用Yul 优化器.
-
使用自定义优化器步骤序列。
-
存在 FullInliner 步骤(i)在序列中。
-
非表达式拆分形式的代码能够到达 FullInliner 步骤。
用户通常无法精确控制代码生成器输出的代码是否为这种形式。但是,可以保证通过 ExpressionSplitter 步骤(x)的代码是表达式拆分的,而通过 ExpressionJoiner 步骤(j)的代码则相反。因此,i 始终由 x 且没有 j 的中间出现的序列是安全的。其他序列可能会或可能不会受到影响,具体取决于其确切的结构。
缺少用户提供的 Yul 代码(以内联汇编或纯 Yul 输入的形式)会大大降低触发该错误的可能性。
该错误会影响传统和基于 IR 的编译管道。这主要是因为内联汇编会通过 Yul 优化器,无论使用哪个管道。此外,某些语言功能即使由传统代码生成器编译,也会从内部 Yul 代码编译。
技术细节
错误行为的直接原因是 FullInliner 不会保留其内联的函数调用中参数的求值顺序。当参数本身是带有副作用的函数调用时,顺序的改变可能会改变合约执行的结果。
最初假设求值顺序不会造成任何问题,因为重要的函数调用永远不应该被内联。该步骤旨在遵循 ExpressionSplitter,后者将代码转换为表达式拆分形式,其中所有调用参数都是简单的标识符。输入中出现的任何非表达式拆分调用都应该被忽略,但事实证明并非如此。
尽管存在这些缺陷,但该问题在实践中并未影响编译器,因为默认的优化器序列经过精心选择,始终在 FullInliner 之前运行 ExpressionSplitter。但是,后来引入允许用户提供自定义序列的机制使得触发该错误成为可能。
优化器步骤序列
Yul 优化器的工作原理是细化代码生成器在 离散步骤中产生的未优化 IR,每个步骤都接收前一个步骤的输出。步骤的顺序由步骤序列决定。
最初,该序列在编译器的源代码中是硬编码的。版本 0.6.7 引入了选项,允许用户 自定义要执行的步骤及其顺序。这是通过标准 JSON 中的 settings.optimizer.details.yulDetails.optimizerSteps 或 CLI 上的 --yul-optimizations 完成的。
选择一个好的序列是一项具有挑战性的任务。它需要了解各个步骤的工作原理以及哪些其他步骤以有利或不利于其操作的方式转换代码。例如,许多步骤都受益于代码处于 SSA 形式。 SSATransform 步骤(a)提供了这种形式,但也引入了冗余赋值,这就是为什么它通常与 RedundantAssignEliminator(r)结合使用的原因。 ConditionalSimplifier 是一个在代码处于 SSA 形式时效果最佳的步骤,但它会破坏此属性,这可能会影响序列中稍后执行的步骤的有效性。
默认序列 经过精心选择并在一段时间内进行了改进,以提供经过良好优化的代码。虽然它还有改进的空间(尤其是在性能方面),但它确保满足所有步骤的先决条件。
Yul 中的参数求值顺序
在 Yul 中,函数参数的求值顺序始终是从右到左。例如
f(add(mload(0), mload(x)), mload(1))
上面的表达式将按以下顺序求值
- mload(1)
- mload(x)
- mload(0)
- add(...)
- f(...)
请注意,当表达式没有副作用时,顺序无关紧要。即使 mload(0) 在 mload(x) 之前求值,此代码片段的行为也不会改变。
但是,引入带有副作用的表达式会改变这一点
function store(x, y) -> r { sstore(x, y) r := y } add(sload(0), store(0, k))
在上面的代码中,在 sload() 之后求值 store() 将改变结果。
表达式拆分形式
表达式拆分器的作用是将代码转换为更简单的形式,以便某些其他优化器步骤更容易使用,并且不受求值顺序选择的影响。此转换涉及首先求值调用参数并将结果存储在新的变量中。然后将这些变量传递给调用。
例如
add(sload(0), store(0, k))
将转换为
let _a := k let _b := 0 let _x := store(_b, _a) let _c := 0 let _y := sload(_c) let _z := add(_y, _x)
请注意,在转换后的代码中,副作用的顺序没有歧义,并且以不同的顺序求值函数参数不会影响它。
FullInliner 和函数调用的内联
FullInliner 使用启发式方法来确定哪些函数调用最好用函数体中的指令替换,并执行替换。就其本身而言,这在大多数情况下会使代码不太优化,因为它会增加代码大小并且不会对执行成本产生重大影响。但是,此步骤的好处是它使其他步骤(通常不跨函数边界工作)能够更好地优化生成的代码。
在内联函数时,内联器需要确保其参数的求值方式与调用中的相同。这涉及在内联点用局部变量替换它们,并重命名它们以避免与周围代码中使用的标识符冲突。
Yul 有三种可以作为调用参数的表达式
- 字面量(例如 0x42 或 0)
- 标识符(例如 k 或 ret)
- 函数调用(例如 f(x) 或 sstore(0, sload(x)))
让我们考虑一个如下定义的函数 f()
function f(a, b) -> r { let c := mul(a, 4) let r := add(c, b) }
当使用字面量或标识符调用时,内联很简单
let k := 0x42 f(24, k)
只要我们注意重命名变量以避免名称冲突,就可以简单地用函数体替换调用
let k := 0x42 let _q := 24 let _p := k let _c := mul(_q, 4) let _r := add(_c, _p)
但是,引入函数调用作为参数需要更加注意求值顺序。现在让我们考虑两个具有副作用的函数
function rev() -> r { revert(0, 0) } function ret() -> r { return(0, 0) }
在下面的调用中,求值顺序决定了事务是回退还是成功终止。在这种情况下,它应该回退
f(ret(), rev())
遇到这样的代码时,FullInliner 应该避免内联。这在正常情况下不会降低优化器的有效性,因为内联器旨在与 ExpressionSplitter 配合使用,后者确保参数始终是简单的标识符。
由于该错误,FullInliner 将无论如何内联此类调用,就像参数是标识符一样,并将参数表达式放在与通常的 Yul 求值顺序不同的顺序中
_q := ret() _p := rev() let _c := mul(_q, 4) let _r := add(_c, _p)
上面的内联代码现在将(错误地)成功终止而不是回退。
错误的影响
该错误有可能以非常显著的方式改变合约的行为。重新排序回退或返回值可能会导致存储写入、内存写入或事件发出未执行。它也可能导致合约在应该回退时不回退(因此不会回滚某些操作),反之亦然。
需要注意的是,除非使用的优化器序列明确包含一个在 FullInliner 之前主动将代码转换为非表达式拆分形式(即 ExpressionJoiner)的步骤,否则高级 Solidity 代码不太可能受到影响。
在没有这种情况的情况下,触发该错误可能需要内联汇编或用纯 Yul 编写的合约。这是因为 Solidity 代码中的嵌套函数调用不会直接转换为生成的 Yul 中的嵌套调用。IR 管道生成的代码主要采用表达式拆分形式,其中每个表达式都分配给一个单独的变量,因为这种形式的代码非常易于生成。编译器确实在其自己的辅助函数中生成非表达式拆分形式的调用,但这些调用很少有除了简单回退之外的其他副作用。
影响
触发该漏洞所需的一系列复杂条件使得它不太可能在实际部署的合约中发生。
首先,使用自定义优化器序列并不常见。此功能有意没有被广泛宣传。以提供实际收益的方式使用它并不容易。任何有足够知识使用它的人也可能会避免触发此漏洞所需的低效序列。
此外,该漏洞不会影响绝大多数的 Solidity 代码。最有可能被其破坏的代码是内联汇编,而内联汇编通常只占整个合约的一小部分。代码还必须实际包含带有副作用的嵌套函数调用。
因此,虽然该漏洞可能产生严重后果,但其对实际项目的总体影响较低。