警惕未定义行为!Solidity隐蔽漏洞竞赛冠军解析

本文深入分析了2022年Solidity隐蔽编码竞赛冠军作品,揭示Solidity未定义行为如何导致看似良性的AMM合约存在严重安全漏洞,包含addmod/mulmod运算和事件参数求值顺序等关键技术细节。

警惕未定义行为!— Solidity隐蔽编码竞赛冠军解析

今年的Solidity隐蔽编码竞赛收到了许多优秀投稿,凸显了Solidity中可能困扰开发者和审计人员的各种特性。我们很荣幸能作为评委参与这项赛事,更令人欣喜的是,今年我们区块链安全工程师Tynan的投稿凭借利用Solidity中一个鲜为人知的特性赢得了比赛。该行为是Tynan与ChainSecurity合作完成的苏黎世联邦理工学院论文的分析内容之一。下文我们将描述Solidity中未定义行为的问题,以及如何利用它为2022年Solidity隐蔽编码竞赛构建一个看似良性实则恶意的AMM智能合约。

未定义行为

未定义行为是许多开发者可能之前就遇到过的术语。但它具体意味着什么?当使用具有多个竞争性编译器的编程语言时,拥有语言规范至关重要,这样才能确保同一程序无论使用哪个编译器都能产生相同输出。然而,如果语言规范不够精确,或故意忽略某些边界情况,这意味着每个编译器可以自行决定如何处理特定情况。例如,在C语言中,整数除以0的结果是未定义的。

这意味着每个C编译器可以按自己喜欢的方式编译这段代码。通常,这意味着选择最方便的方式,或抛出错误。然而,编译器也可以做完全不同的事情,仍然算是C语言的"正确"实现。

作为开发者,避免此类未定义行为非常重要。这意味着你不再完全控制编译后的代码。虽然大多数时候,人们可能认为只有晦涩的边界情况会被留作未指定,但在某些情况下,触发未定义行为可能比你预期的更容易。

Solidity中的未定义行为

Solidity的情况与C语言略有不同。只有一个Solidity编译器,语言规范也随其发展而演变。这不一定是异常情况,但导致了一些怪异行为。

例如,在使用合约继承时,规范未指定是先初始化所有状态变量,还是仅在运行所属合约的构造函数之前初始化状态变量。Solidity文档中的这个示例很好地说明了这一点:

1
2
3
4
5
6
7
contract A {
    uint x = 42;
}

contract B is A {
    uint y = x;
}

这里,y可能是0或42,取决于在A的构造函数执行前是否初始化了y。

当然,这个示例相当刻意;不太可能有人真的会创建这种结构的合约。此外,只要编译器不改变处理这种行为的方式,你依赖它对此情况的处理实际上并不重要。

不幸的是,根据你使用的是默认编译器设置,还是通过Yul中间表示进行的新实验性编译,你可能会得到任一种行为。鉴于Yul流水线最终应该取代当前流水线,开发者不依赖这些行为非常重要。

求值顺序

为什么未定义行为很重要,如果我们只在特定构造的示例中遇到它?唉,正如我们在Solidity文档中看到的,表达式的求值顺序是未指定的。这意味着另一个表达式中的子表达式可以按任何顺序求值。例如,让我们看看表达式f(g(...), h(...))。当然,g和h需要在f之前求值,因为f依赖于它们的输出。然而,求值顺序可能是g -> h -> f或h -> g -> f。

如果g和h没有副作用,一切正常。如果它们读取/写入内存、存储或进行外部调用,我们可能很快会遇到问题。

1
2
uint256 i = 0;
f(i, i++);

在这个例子中,直觉上我们可能期望结果是f(0, 0)。然而,编译器也可能选择在i之前求值i++,因此结果可能是f(1, 0),同时仍然遵循语言规范。自然,除非Solidity编译器确实有时以意外顺序求值表达式,否则这并不太重要。

addmod和mulmod

我们要看的第一个案例是addmod和mulmod。这些不是你在Solidity代码中经常看到的函数,但它们在任何Solidity合约中都是全局可用的。addmod(a, b, N)简单计算(a + b) mod N,而mulmod(a, b, N)计算(a * b) mod N。但是当N = 0时会发生什么?在EVM级别,相应的操作码简单地返回0。Solidity团队不希望向开发者暴露这种可能意外的结果,因此他们断言N != 0,否则回退。如果此检查失败,不需要求值a和b,因此编译器按从右到左的顺序求值参数。因此,以下示例结果为1,因为先求值了a++。

1
2
uint256 a = 1; 
addmod(a, a++, 2);

事件

事件的情况更为复杂。一个事件可以有索引和非索引参数。同样,在发出事件时,嵌套表达式的求值顺序是未指定的。在这种情况下,编译器选择首先按从右到左的顺序求值索引参数,然后按从左到右的顺序求值非索引参数。因此,以下示例按h -> f -> g -> i的顺序求值。

1
2
event Hello(uint indexed a, uint b, uint indexed c, uint d);
emit Hello(f(...), g(...), h(...), i(...))

更复杂的是,通过Yul IR进行的编译尝试按从左到右的顺序求值所有内容,但不保证这一点。因此,如果你指定--experimental-via-ir编译器选项,上述示例确实会产生预期行为。事实上,默认编译和实验性Yul编译器之间存在许多小差异。Solidity团队在此处编制了此类差异的列表。

隐蔽Solidity

虽然未定义行为不太可能导致智能合约中的意外利用,但这并不能阻止恶意开发者故意引入它们。2022年Solidity隐蔽编码竞赛的获胜作品就证明了这一点。

该投稿是一个相对简单的去中心化交易所。它实现了恒定乘积交易系统。每笔交易会产生一些费用,这些费用被放入流动性池中。交易所的所有者可以索取管理费,该费用根据自上次索取费用以来流动性的增加计算。

重要的是,这种计算意味着管理费可以追溯索取——如果所有者更改费用然后索取它们,索取前累积的流动性仍将包含在计算中。

为了缓解这种情况,管理费更改过程强制所有者在更改费用时索取费用。此外,还有7天的等待期,管理员在此期间不能索取费用,以便让流动性提供者在索取更高资金之前提取他们的资金。

然而,正如我们所见,索引事件参数是按从右到左求值的。这意味着setNewAdminFeeretireOldAdminFee之前执行。因此,前一期的费用实际上是用新设置的费率索取的!事实上,在这个特定合约中,没有最高管理费限制,因此所有者可以设置任意高的费用以耗尽整个底层余额。

结束语

要深入了解其他获胜者能够使用的内容,请查看Solidity团队的优秀总结。如果你有兴趣与我们一起揭示基于区块链的系统的特性,请联系jobs@chainsecurity.com;如果你需要智能合约审计或其他确保区块链项目安全的保证,请通过info@chainsecurity.com与我们联系。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计