2022 年 6 月 5 日,Certora 开发团队的 John Toman 报告了一个优化器错误,在某些情况下会导致内联汇编块中的内存写入被错误地删除。
该错误是在 Solidity 0.8.13 中引入的,使用新的 Yul 优化器步骤旨在删除对内存和存储的未使用写入。
我们对该错误的严重程度进行了“中等”评级。
哪些合约受影响?
Yul 优化器将最外层 Yul 块中从未读取的所有内存写入视为未使用,并将它们删除。当该 Yul 块是整个 Yul 程序时,这是有效的,对于由新 via-IR 管道生成的 Yul 代码来说始终如此。使用该管道时,内联汇编块永远不会被单独优化。相反,它们作为整个 Yul 输入的一部分进行优化。
但是,旧的代码生成管道(仍然是默认的)如果内联汇编块不引用周围 Solidity 代码中定义的任何变量,则会在内联汇编块上单独运行 Yul 优化器。因此,即使写入的内存稍后被访问,例如由随后的内联汇编块访问,如果写入的内存从未在同一个汇编块中读取,则此类内联汇编块中的内存写入也会被删除。
幸运的是,旧的代码生成管道根本不会在访问 Solidity 变量的内联汇编块上运行 Yul 优化器,这大大减少了受影响的情况。大多数内联汇编块要么从周围 Solidity 代码中定义的变量读取或写入值,要么完全自包含,要么接管程序流程,直到事务结束。因此,该错误不太可能在实践中发生,并且在大多数情况下,其不利影响应该在测试中很容易检测到。但是,由于受影响情况的后果可能很严重,因此我们对其严重程度进行了“中等”评级。
在以下示例中,启用了优化器的旧代码生成管道将删除mstore 指令,函数 f 将返回零
contract C { function f() external pure returns (uint256 x) { assembly { mstore(0, 0x42) } assembly { x := mload(0) } } }
但是,如果相同的内存在同一个内联汇编块中再次被读取,或者如果内联汇编块访问任何本地 Solidity 变量,则该错误就不会出现。在以下示例中,这两种情况都存在,因此 f 将如预期那样返回 0x42
contract C { function f() external pure returns (uint256 x) { assembly { mstore(0, 0x42) x := mload(0) } } }
在以下示例中,第一个 mstore 不会被删除,因为写入的内存被 return 再次读取。另一方面,第二个 mstore 将被删除,因为写入的内存从未再次读取。在这种情况下,这是一个有效的优化,并且示例不受该错误的不利影响。一般来说,任何终止事务或没有需要在之后观察的内存副作用的汇编块都不会受到影响。
contract C { function f() external { assembly { mstore(0, 0x42) // This write will be kept, since the return below reads from the memory. mstore(32, 0x21) // This will be removed, but that is valid since the memory is never read again. return(0, 32) } } }
受该错误影响的最危险的情况是那些在某个汇编块(例如辅助函数)中使用固定内存偏移量存储中间值,然后仅在后续汇编块中使用这些值的情况。
contract C { function callHelper() internal view { assembly { let ret := staticcall(gas(), address(), 0, 0, 0, 0) if eq(ret, 0) { revert(0, 0) } returndatacopy(0, 0, 128) // This will be removed due to the bug. } } function f() external view returns(uint256 x) { callHelper(); assembly { // This consumes the memory write by the helper, which was incorrectly removed. x := keccak256(0, 128) } } }
但是,我们发现这种模式并不常见:通常,一个汇编块要么从本地 Solidity 变量读取或写入,要么直接使用它写入的内存,要么接管控制权直到事务结束。这部分是由于在汇编块之间使用固定内存偏移量存储中间值本身就是危险的,因为您需要确保汇编块之间的 Solidity 代码不会再次覆盖该内存。
一个可能严重受影响的模式是在事务开始时通过写入空闲内存指针(由于该错误将被删除)来预留静态内存
contract C { // Modifier meant to allow the safe use of 64 bytes of static memory at offset 0x80. modifier reserveStaticMemory() { assembly { // Assert that this is called with the expected initial value of the free memory pointer. if iszero(eq(mload(0x40), 0x80)) { revert(0, 0) } // Reserve 64 bytes of memory between 0x80 and 0xC0. mstore(0x40, 0xC0) // This write will be removed due to the bug. } _; } function someHelper(bytes calldata s) internal pure { bytes32 hash = keccak256(s); assembly { // Store some intermediate values to the supposedly reserved memory. mstore(0x80, 0x12345678) mstore(0xA0, hash) } } function f(bytes calldata s, bytes calldata y) external view reserveStaticMemory returns(uint256 x) { someHelper(s); bytes32 hash = keccak256(y); // Since the free memory pointer was not actually bumped, this will overwrite the memory at 0x80. assembly { x := keccak256(0x80, 0x40) // The memory expected here will have been overwritten. x := xor(x, hash) } } }