2022 年 9 月 5 日,通过差异化模糊测试发现 Solidity 的 Yul 优化器存在漏洞。
该漏洞是在 0.8.13 版本中引入的,Solidity 版本 0.8.17 于 2022 年 9 月 8 日发布,提供了修复。该漏洞在使用优化的 via-IR 代码生成时更容易触发,但理论上也可能发生在优化的传统代码生成中。
我们为该漏洞分配了“中/高”严重程度。
谁应该关注
如果您使用的是优化的传统代码生成,您只需要关注,如果您使用的是包含用户定义的汇编函数的大的内联汇编块,这些函数涉及return(...) 或 stop() 指令。此外,只有不与任何周围 Solidity 变量交互的内联汇编块才会受到影响。
如果您使用的是优化的 via-IR 代码生成,您更有可能受到影响。
您应该仔细检查您的合约,只要它涉及任何代码路径,该路径首先写入存储,然后继续使用汇编 return(...) 或 stop() 提前终止,但也可以有条件地绕过此提前终止。这包括存储写入或提前终止发生在复杂控制流或任意嵌套函数调用中的情况。
哪些合约受到影响?
合约受到影响的先决条件是在内联汇编块中使用 return(...) 或 stop()(请注意,return(...) 这里指的是内置内联汇编函数,而不是 Solidity 的 return 语句)。这样的内联汇编调用不会从当前函数返回,而是导致整个外部 EVM 调用的提前成功(即非回退)终止。
如果您的合约不包含任何带有 return(...) 或 stop() 语句的内联汇编块,您就不会受到影响。
如果它确实存在,并且这种提前终止有条件地在函数内发生,优化器可能会错误地移除函数调用之前的存储写入。
更具体地说,如果一个合约包含以下模式,则可能会出现此漏洞
- 存储写入。请注意,即使写入仅有条件地发生,或者在最终被内联的函数调用中发生,该写入也可能被移除。
- 调用一个使用内联汇编按上述方式有条件终止的函数,但它也具有返回调用者的不同代码路径。
- 任何继续的控制流路径执行以下操作之一
- 它覆盖了 (1) 中的存储写入。
- 它回退。
如果最初的存储写入可能在 (1) 和 (3) 之间被读回(直接或在任何内部或外部调用中),它当然永远不会被移除。但是,请注意,在优化器存在的情况下,并不总是容易确定 Solidity 级别的存储读取是否真的会转化为汇编中的加载指令。例如,加载解析器步骤可以使用对合约之前写入的值的了解来直接用它将要读取的值替换 sload()。
使用传统代码生成时,所有这些,即步骤 1-3,都需要在单个内联汇编块中完成,并且 (2) 中的函数调用需要是用户定义的汇编函数的调用(有关受影响的 Yul 代码段示例,请参见下文)。还要注意,在传统代码生成期间,Yul 优化器只在不引用 Solidity 变量的内联汇编代码段上运行,这进一步减少了可能受影响的情况的数量。
但是,当使用 via-IR 代码生成时,整个合约首先被翻译成 Yul,然后作为一个整体进行优化。在这种情况下,1-3 可以发生在 Solidity 代码中,只有 (2) 中调用的函数需要包含调用 return(...) 或 stop() 的内联汇编块(编译器不会生成 return(...) 或 stop() 指令,这些指令可以在没有使用内联汇编的情况下触发此漏洞)。请注意,由于内联,此内联汇编块也可能只出现在另一个嵌套函数调用中,即 (2) 中的任何调用,通过任何嵌套调用的链,都可以返回调用者并通过汇编终止,都容易受到攻击。
以下是如果通过 IR 编译且启用了优化器,将显示该漏洞的最小示例
contract C { uint public x; function f(bool a) public { x = 1; // This write is removed due to the bug. g(a); x = 2; } function g(bool a) internal { // The relevant part of this function is that it can // both return to the caller and terminate the transaction. // The bug will show its effects in the cases in which // the transaction is terminated (i.e. if a is false). // In this case the write x = 1 above will be missing. if (a) return; assembly { return(0,0) } } }
通过 IR 编译上述代码并启用优化器将导致 f(false) 错误地终止交易,而不会修改 x。
对于具体的合约,优化器仍然可以通过在运行有问题的优化步骤之前内联函数来防止此漏洞,例如,以下合约几乎相同,但不受影响,因为 g() 将被内联(在上文中,Solidity 级别的 return; 阻止了轻松内联)
contract C { uint public x; function f(bool a) public { x = 1; g(a); x = 2; } function g(bool a) internal { if (!a) assembly { return(0,0) } } }
要检查您的合约是否确实受到影响,您需要触发导致汇编 return(...) 或 stop() 的条件,并验证导致它的代码中应该发生的存储写入是否都已正确执行。
技术细节
导致此漏洞的 Yul 优化器步骤是未用存储消除器(在 优化器步骤序列 中缩写为 S)。它旨在移除它可以确定为冗余的存储写入。如果在初始写入的值可能被再次读回之前,在继续进行初始写入之后的任何代码路径中都发生了以下情况之一,则存储写入被认为是冗余的
- 随后的写入覆盖了初始写入中写入的值。
- 代码路径无条件回退。
因此,与上面类似,一般模式是
- 存储写入(可能在复杂的控制流内),然后
- 可能不相关的代码,最后
- 任何继续的控制流路径要么用不同的值覆盖 (1) 中的存储写入,要么回退。
该漏洞是由于对 (2) 中某些函数调用的处理造成的。
如果在 (1) 和 (3) 之间执行函数调用,优化器必须考虑函数调用的控制流行为,例如,控制流是否可能在调用之外继续,被调用的函数是否总是回退或总是成功终止。但是,在控制流可以有条件地继续在调用函数之后,但函数调用也可以使用 return(...) 或 stop() 语句终止(参见以下示例)的情况下,优化器错误地仍然像控制流一样 始终 继续调用函数之后。
因此,(1) 中的写入可能会被移除,即使 (2) 实际上可以终止,并且应该保留 (1) 中的存储写入。
所以在 Yul 级别,如果在优化期间 Yul 块内出现以下序列,问题就会显现
- 发生存储写入(例如 sstore(0, 1),但写入也可能发生在复杂的控制流内,例如单个 switch 案例中)。
- 调用用户定义的函数,该函数既有终止交易的控制流路径,也有退出函数的控制流路径。
- 任何继续的控制流路径执行以下操作之一
- 它覆盖了 (1) 中写入的存储槽,这样 (1) 中的存储写入对于此路径就变得冗余了(例如 sstore(0, 2))。
- 控制流无条件回退(例如 revert(0, 0))
在这种情况下,优化器会将 (1) 中的存储写入视为冗余并将其移除,尽管事实上被调用的 Yul 函数可能成功终止 EVM 调用,在这种情况下,此存储写入实际上根本不冗余。
以下是一个受影响的 Yul 代码段的完整示例
{ function f() { if gt(calldatasize(), 4) { leave } return(0, 0) } sstore(0, 1) // This sstore will incorrectly be removed. f() sstore(0, 2) }
在没有漏洞的情况下,当遇到对 f() 的调用时,优化器会将第一个 sstore 标记为必需,即,无论之后发生什么,它都不应该被移除,因为到达 return(0, 0) 的 f() 的控制流路径已经依赖于 sstore 的发生。但是,优化器未能将其标记为这样,而是当它看到第二个 sstore 覆盖该槽时,反而将第一个 sstore 标记为冗余。
该问题只会在存在对有条件终止的 Yul 函数的调用时发生,该调用在先前的优化步骤中幸存下来。以下非常类似的代码段不会受到影响,因为优化器会在执行有问题的优化步骤之前内联对 f() 的调用
{ function f() { if iszero(lt(calldatasize(), 4)) { return(0,0) } } sstore(0, 1) f() sstore(0, 2) }
在这种情况下,f() 将被内联,得到
{ sstore(0, 1) if iszero(lt(calldatasize(), 4)) { return(0,0) } sstore(0, 2) }
在这种情况下,两个 sstore 都被正确保留。
但是,请注意,优化器会传递地确定这些属性,因此即使我们假设 emptyReturn 不会被内联,以下情况仍然会受到影响
{ function emptyReturn() { return(0, 0) } function f() { if iszero(lt(calldatasize(), 4)) { emptyReturn() } } sstore(0, 1) f() sstore(0, 2) }