本文从源代码层面详解介绍了 Solidity( 0.8.13<=solidity<0.8.17)编译器在编译过程中,因为 Yul 优化机制的缺陷导致的状态变量赋值操作被错误删除的中/高漏洞原理及相应的预防措施。
帮助合约开发人员提高合约开发时的安全意识,有效规避或缓解 SOL-2022-7 漏洞对合约代码安全性的影响。
1.漏洞详情
Yul 优化机制是 Solidity 编译合约代码的可选项,可以通过优化机制减少合约中某些冗余的指令,从而降低合约部署和执行过程中的 gas 费用,具体的 Yul 优化机制可以参考官方文档。
在编译过程的 UnusedStoreEliminator 优化步骤中,编译器会将“冗余”的 Storage 写入操作移除,但由于对“冗余”的识别缺陷,当某个 Yul 函数块调用特定的用户定义函数(函数内部存在某个分支不影响调用块的执行流),且在该 Yul 函数块中被调用函数前后存在对同一状态变量的写入操作,会导致在 Yul 优化机制将块中该用户定义函数被调用前的所有的 Storage 写入操作从编译层面被永久删除。
考虑如下代码:
contract Eocene {
uint public x;
function attack() public {
x = 1;
x = 2;
}
}
在 UnusedStoreEliminator 优化时,x= 1 显然对于函数 attack()的整个执行是冗余的。自然的,优化后的 Yul 代码会将 x= 1;删除来降低合约的 gas 消耗。
接下来考虑在中间插入对自定义函数调用:
contract Eocene {
uint public x;
function attack(uint i) public {
x = 1;
y(i);
x = 2;
}
function y(uint i) internal{
if (i > 0){
return;
}
assembly { return( 0, 0) }
}
}
显然,由于 y()函数的调用,我们需要判断 y()函数是否会影响函数 attack()的执行,如果 y()函数可以导致整个函数执行流终止(注意,不是回滚,Yul 代码中的 return()函数可以实现),那么 x= 1 显然是不能删除的,所以对于上面的合约来说由于 y()函数中存在 assembly {return( 0, 0)}可以导致整个消息调用终止,x= 1 自然不能被删除。
但在 Solidity 编译器中,由于代码逻辑的问题,使得 x= 1 在编译时被错误的删除,永久改变了代码逻辑。
实际编译测试结果如下:
震惊!不应该被优化的 x= 1 的 Yul 代码丢了!欲知后事如何,请往下看。
在 solidiry 编译器代码的 UnusedStoreEliminator 中,通过 SSA 变量追踪和控制流追踪来判断一个 Storage 写入操作是否是冗余的。当进入一个自定义函数中时,UnusedStoreEliminator 如果遇到:
memory 或 storage 写入操作:将 memory 和 storage 写入操作存储到 m_store 变量中,并将该操作的初始状态设置为 Undecided;
函数调用:获取函数的 memory 或 storage 读写操作位置,并和 m_store 变量中存储的所有 Undecided 状态下的操作进行对比:
1.如果是对 m_store 中存储操作的写入覆盖,则将 m_store 中对应的操作状态改为 Unused
2.如果是对 m_store 中存储操作的读取,则将对应 m_store 中的对应操作状态改为 Used
3.如果该函数没有任何可以继续执行消息调用的分支,将 m_store 中所有的内存写操作改为 Unused
1.在上诉条件下,如果函数可以终止执行流,将 m_store 中,状态为 Undecided 状态的 storage 写操作改为 Used;反之,标识为 Unused
函数结束:将所有标记为 Unused 的写入操作删除
对 memory 或 storage 写入操作的初始化代码如下:
可以看到,将遇到的 memory 和 storage 写入操作存储到 m_store 中
遇到函数调用时的处理逻辑代码如下:
其中,operationFromFunctionCall()和 applyOperation()实现上诉的 2.1 , 2.2 处理逻辑。位于下方的基于函数的 canContinue 和 canTerminate 进行判断的 If 语句实现 2.3 逻辑。
需要注意,正是下方的 If 判断的缺陷,导致了漏洞的存在!!!
operationFromFunctionCall()来获取该函数的所有 memory 或 storage 读写操作,这里需要注意,Yul 中存在很多的内置函数,例如 sstore(), return()。这里可以看到对于内置函数和用户定义函数有不同的处理逻辑。
而 applyOperation()函数则是将从 operationFromFuncitonCall()获取的所有读写操作进行对比,来判断存储到 m_store 中的是否在该次函数调用中被读写,并修改 m_store 中的对应的操作状态。
考虑上述的 UnusedStoreEliminator 优化逻辑对 Eocene 合约的 attack()函数的处理:
将 x= 1 存储操作到 m_store 变量中,状态设置为 Undecided
1.遇到 y()函数调用,获取 y()函数调用的所有读写操作
2.遍历 m_store 变量,发现 y()调用引起的所有读写操作和 x= 1 无关,x= 1 状态仍然是 Undecided
1.获取 y()函数的控制流逻辑,因为 y()函数存在可以正常返回的分支,所以 canContinue 为 True,不进入 If 判断。x= 1 状态仍然为 Undecided!!!
3.遇到 x= 2 存储操作:
1. 遍历 m_store 变量,发现处于 Undecided 状态的 x= 1 ,x= 2 操作覆盖 x= 1 ,设置 x= 1 状态为 Unused。
2. 将 x= 2 操作存入 m_store,初始状态为 undecided。
4. 函数结束:
1. 将所有 m_store 中 undecided 状态的操作状态改为 Used
2. 将所有 m_store 中 Unused 状态的操作删除
显然,在调用函数时,如果被调用函数可以终止消息执行,应该将被调用函数前所有的 Undecided 状态的写入操作改为 Used,而不是依旧保留为 Undecided,导致位于被调用函数前的写入操作被错误的删除。
此外,需要注意的是,每个用户自定义函数控制流标识是会传递的,所以在多个函数递归调用的场景下,即便最底层函数满足上诉逻辑,x= 1 也有可能被删除。
在Solidity中,举例了基本相同的逻辑下,不会受到影响的合约代码。但,该代码不受该漏洞的影响并不是因为 UnusedStoreEliminator 的处理逻辑存在其他可能,而是在 UnusedStoreEliminator 之前的 Yul 优化步骤中,存在 FullInliner 优化过程会将微小或只有一次调用的被调用函数,嵌入到调用函数中,避免了漏洞触发条件中的用户定义函数。
contract Normal {
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) }
}
}
编译结果如下:
函数 g(bool a)被嵌入到函数 f()中,避免了用户定义函数的漏洞条件,避免了漏洞的产生。
2.解决方案
最根本的解决方案是不使用在受影响范围的 solidity 编译器进行编译,如果需要使用漏洞版本的编译器,可以考虑在编译时去除 UnusedStoreEliminator 优化步骤。
如果想要从合约代码层面进行漏洞缓解,考虑到多个优化步骤的复杂性,以及实际函数调用流的复杂性,请寻找专业的安全人员进行代码审计来帮助发现合约中的因为该漏洞导致的安全问题。