本文讨论了2019年在Solidity编译器中发现的一个内存管理漏洞,导致动态数组的长度计算溢出,并可能导致内存损坏。文章深入分析了该漏洞的原理、示例代码及其潜在影响,还探讨了如何避免及检测此类漏洞的方法。最后,作者提到应用形式化方法来提高编译器的正确性和区块链智能合约的安全性。
在2020年4月,我们发现了Solidity编译器中的一个漏洞(在版本0.6.5中修复),该漏洞导致动态大小数组的长度在分配时可能会溢出。之后对数组的写操作通过不正确的长度进行了边界检查,从而可能允许越过数组末尾的写入,进而潜在地损坏内存中的其他数据。
这个漏洞提醒我们,漏洞可能隐藏在源代码与字节码之间的缝隙中。任何假设不同内存分配中的写操作没有干扰的程序分析,对于使用旧版本Solidity编译的合约来说都是不可靠的。事实上,这个漏洞是在开发字节码级内存分析时由Certora的John Toman发现的。我们于2020年3月27日将其报告给Solidity团队,团队于4月6日发布了修复版本。
EVM的内存模型是一个平坦的字节数组;没有原始的分配操作,也没有释放操作。在字节码层级,智能合约负责根据其所需管理这个数组,例如,通过将其拆分成不同的区域来存放不同逻辑对象的数据。
在实践中,Solidity编译器抽象了这个分段过程,并生成低级的内存管理操作。编译器使用递增的“空闲指针”来指示内存数组中未用空间的起始索引。在每次分配时,编译器保存当前的空闲指针值,将指针递增按已分配对象的大小,然后返回保存的值作为新分配内存的“指针”。
Solidity中的数组在内存中以一个块表示,这个块以一个包含数组长度的256位字开始,后跟 n 个数组元素,每个元素始终为32字节(必要时进行填充)。为了分配动态大小数组所需的字节数,Solidity编译器生成的代码会将空闲指针增加 (n * 32) + 32;即,为数组的每个元素分配32个字节,加上长度元素的额外32个字节。
在索引 i 处读取和写入数组元素的边界通过将 i 与数组第一个单元的长度进行比较来检查。如果检查成功,则第 i 个元素的字节寻址指针计算为 base_ptr + (i * 32) + 32,即跳过块开头的32字节长度字段。假设Solidity编译器正确管理了空闲指针,智能合约可以确保从结果内存索引的读取和写入是与内存中所有其他分配对象的独立访问。
该漏洞源于在分配过程中计算动态大小数组字节长度所涉及的乘法。如果 n 被选择为接近可表示的最大整数,则 n * 32 将溢出,回绕。结果是实际分配的字节数远小于预期。Solidity编译器在0.6.5版本之前没有检查这种溢出。如果合约允许不受信任的输入影响动态大小内存数组的长度,攻击者可能会导致长度计算溢出。
结果是新分配内存的前32个字节中存储的数组长度与通过递增空闲指针实际分配的内存量之间存在差异。(重要的是,存储的数组长度表示分配的元素数量,而不是分配的字节数量)。这导致后续的边界检查即使在结果EVM内存索引超过实际分配范围时也能通过。如果攻击者能够让对超出已分配内存末尾的索引进行写入,他们就可能覆盖存储在内存中的任何其他对象。
以下代码包含一个刻意设计的Solidity程序示例,演示了这个编译器漏洞的一些潜在影响。该程序仅作为漏洞的简单演示; 它并不反映智能合约在现实中如何通常使用数组。
pragma solidity >= 0.6.0 <= 0.6.5;
contract CorruptedState {
struct OwnedTokens {
uint owned;
uint promised;
}
mapping (address => OwnedTokens) ownership;
// 以下方法分配一个长度为 `sz` 的 uint256 记忆数组。
// (第1行)
// 因此,内存数组所需的字节长度为 sz * 32。
// 如果 sz 足够大,字节长度将无法表示为 uint256 并将溢出,
// 从而实际从空闲内存指针中分配的字节数会很少。
// 因此存储在数组首个字中的元素长度将与实际分配的字节数不一致。
// 第2行然后将结构体复制到内存中“在” tmp 结束之后
// (由于只分配了几个字节,所以实际上是在 tmp 的某个中间位置)。
// 第3行是一个看似无害的写操作,如果 sz 正确选择,
// 这实际上可能影响到结构体 t。
// 第4行将 t 复制回储存。
function corruptMemory(uint sz, uint elem) public returns (uint256) {
/* 1 */ uint[] memory tmp = new uint[](sz);
/* 2 */ OwnedTokens memory t = ownership[msg.sender];
/* 3 */ tmp[2] = elem;
/* 4 */ ownership[msg.sender] = t;
/* 5 */ return ownership[msg.sender].owned;
}
function getOwned() public returns (uint256) {
return ownership[msg.sender].owned;
}
}
函数 corruptMemory
接受一个大小 sz
和一个元素 element
。然后它执行看似无害的操作,但在有漏洞的编译器版本下,经过仔细选择的大小可以导致 element
被写入到 t
占用的内存中。由于 t
后来被复制回存储,因此这会导致存储数据损坏。执行内存写入后的内存布局如下所示。
图:刻意设计的Solidity程序中结构体 t 的潜在内存布局。
内存分配从偏移量 0x80 开始。第一个分配的变量(以红色标记)是 tmp
,占用 2 256 字节。 tmp
的长度(深红色标记)位于偏移量 0x80–0xa0 (32字节)。深灰色部分表示从存储中复制的 OwnedTokens
结构体。请注意,由于 tmp
的分配溢出,因此写入结构体的位置覆盖了本应属于 tmp
的内存部分。变量 t
的内存标记为蓝色(它是灰色内存区域的副本)。它与为 tmp
分配的内存重叠,重要的是, tmp[2]
与 t.owned
是同一内存槽。
你可以在 Bug Disclosure 上找到有关此漏洞、其利用和在本地计算机上复现的完整详细信息。
一个合理的问题是,给定Gas成本,上述攻击是否可行。乍一看,似乎初始化如此大的数组需要大量的Gas。然而,结果证明初始化代码也使用数组的字节长度来确定需要初始化多少字节。由于该长度溢出,初始化代码只初始化了从空闲指针中分配的那些字节,从而导致合理的Gas成本。
随着时间的推移,Solidity编译器的安全性和可靠性将继续改善。然而,即使在成熟的编译器中,编译器漏洞也很常见,例如 Toward Understanding Compiler Bugs in GCC and LLVM。这导致了一个活跃的研究领域,应用形式化方法保证编译器的正确性,如 The CompCert C Compiler Validation Homepage。
Certora验证器可以自动分析字节码程序的所有执行。由于分析是在编译器的输出上进行的,它可以检测可能影响合约正确性的编译器漏洞的存在。这意味着,结果并不依赖于编译器本身的正确性。我们期待继续使用字节码分析来提高以太坊社区合约的安全性。
我们感谢Solidity编译器团队对此问题的有益讨论以及快速发布修复。
- 原文链接: medium.com/certora/bug-d...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!