每次我看到+
、*
或**
审计另一个 Solidity 智能合约时,我都会开始写以下评论:“这里可能会溢出”。我需要几秒钟来写这四个字,在这几秒钟内,我观察附近的行,试图找出原因,为什么不可能溢出,或者为什么在这种特殊情况下应该允许溢出。如果找到原因,我会删除评论,但大多数情况下评论会保留在最终审计报告中。
事情不应该是这样的。算术运算符应该允许编写紧凑且易于阅读的公式,例如a**2 + 2*a*b + b**2
. 然而,这个表达式几乎肯定会引起一堆安全问题,真正的代码更可能是这样的:
add (add (pow (a, 2), mul (mul (2, a), b)), pow (b, 2))
这里add
、mul
和pow
分别是实现 、 和 的“安全”版本+
的*
函数**
。
不鼓励简洁方便的语法,很少使用简单的算术运算符(一次不超过一个),到处都是繁琐且不可读的函数语法。在这篇文章中,我们分析了这个问题,它让事情变得如此奇怪,它臭名昭著的名字是:溢出。
有人会说,溢出总是存在的,所有的编程语言都深受其害。但这真的是真的吗?您是否见过类似为 C++、Python 或 JavaScript 实现的SafeMath库?在证明相反的情况之前,您真的认为每个+
或*
都是安全漏洞吗?很可能,您对这两个问题的回答都是“否”。所以,
剧透:无处可逃,无处可藏。
数字在纯数学中不会溢出。可以将两个任意大数相加并得到精确的结果。在 JavaScript 和 Python 等高级编程语言中,数字不会溢出。在某些情况下,结果可能会落入无穷大,但至少将两个正数相加可能永远不会产生负结果。在 C++ 和 Java 中,整数会溢出,但浮点数不会。
在那些整数类型确实会溢出的语言中,纯整数主要用于索引、计数器和缓冲区大小,即用于受正在处理的数据大小限制的值。对于可能超出普通整数范围的值,有浮点数、大整数和大十进制数据类型,它们是内置的或通过库实现的。
基本上,当算术运算的结果不适合参数的类型时,编译器可能会做一些选择:i)使用更宽的结果类型;ii) 返回截断的结果并使用侧通道通知程序溢出;iii) 抛出异常;和 iv) 只是默默地返回截断的结果。
int
在处理类型溢出时,第一个选项在 Python 2 中实现。第二个选项是 CPU 中的进位/溢出标志的用途。第三个选项由 SafeMath 库为 Solidity 实现。第四个选项是 Solidity 自己实现的。
第四个选项可能是最糟糕的一个,因为它使算术运算容易出错,同时使溢出检测非常昂贵,特别是对于乘法情况。为了安全起见,需要在每次乘法后执行额外的除法。
因此,Solidity 既没有安全类型,可以跑到,也没有安全操作,可以躲在后面。无处可逃,无处可躲,开发人员不得不面对溢出并在整个代码中与它们作斗争。
那么,下一个问题是:
剧透:因为 EVM 没有它们。
智能合约必须是安全的。它们中的错误和漏洞会造成数百万美元的损失,因为我们已经从惨痛的教训中吸取了教训。作为智能合约开发的主要语言,Solidity 非常重视安全性。如果有许多功能应该可以防止开发人员搬起石头砸自己的脚。我们指的是payable
关键字、类型转换限制等功能。每个主要版本都会添加此类功能,通常会破坏向后兼容性,但社区为了更好的安全性而容忍这种情况。
然而,基本的算术运算非常不安全,现在几乎没有人直接使用它们,而且情况也没有改善。唯一变得更安全的操作是除法:除以零以前返回零,但现在它抛出异常,但即使是除法也没有变得完全安全,因为它仍然可能溢出。是的,在int
Solidity 类型中,当 -2¹²⁷ 除以 -1 时会溢出,因为正确答案 (2¹²⁷) 不适合int
. 所有其他操作,即+
、-
、*
和**
仍然容易发生溢出或下溢,因此本质上是不安全的。
Solidity 中的算术运算复制相应的 EVM 操作码的行为,并且使这些运算在编译器级别安全会增加数倍的气体消耗。普通ADD
操作码需要 3 个 gas。作者设法找到的用于实现安全添加的最便宜的操作码序列是:
DUP2(3) DUP2(3) NOT(3) LT(3) (3) JUMPI(10) ADD(3)
这
是溢出时跳转的地址。括号中的数字是操作的 gas 成本,这些数字总共给我们 28 gas。几乎是 plain 的 10 倍ADD
。太多了,对吧?这取决于你比较的是什么。比如说,从 SafeMath 库调用add
函数将花费大约 88 gas。
因此,库或编译器级别的安全算术成本很高,但是
剧透:没有充分的理由。
出于性能原因,有人会说 EVM 中的算术语义复制了 CPU 的算术语义。是的,一些现代 CPU 有256 位运算的操作码,但主流 EVM 实现似乎不使用这些操作码。Geth 使用big.Int
Go 编程语言标准库中的类型。这种类型实现了由原生单词数组支持的任意宽大整数。Parity 使用自己的库在本机 64 位字之上实现固定宽度的大整数。
对于这两种实现,算术溢出检测的额外成本几乎为零。因此,一旦 EVM 拥有算术操作码版本,在溢出时恢复,它们的 gas 成本可以与现有的不安全版本相同,或者略高。
更有用的是完全不溢出的操作码,而是返回整个结果。这样的操作码将允许在编译器或库级别有效地实现任意宽的大整数。
我们不知道为什么 EVM 没有上述操作码。也许只是因为其他主流虚拟机没有它们?
到目前为止,我们讲述的是真正的溢出:计算结果太大而不适合结果数据类型的情况。现在是时候发现问题的另一面了:
如何计算 Solidity 中x的 3%?在主流语言中,人们只是写0.03*x
,但 Solidity 不支持分数。怎么样x*3/100
?好吧,这在大多数情况下都有效,但是如果x太大以至于x*3
会溢出怎么办?从上一节我们知道该怎么做,对吧?只需使用mul
SafeMath 并确保安全:mul (x, 3) / 100
......没那么快。
后一个版本更安全一些,因为它会还原前一个版本返回错误结果的位置。这很好,但是……到底为什么计算 3% 的东西可能会溢出?某物的 3% 保证低于原始价值:无论是名义价值还是绝对价值。所以,只要x适合 256 位字,那么x的 3%也应该适合,不是吗?
好吧,我称之为“幻影溢出”:最终计算结果适合结果数据类型,但某些中间操作溢出的情况。
虚拟溢出比真实溢出更难检测和解决。一种解决方案是对中间值使用更宽的整数类型甚至浮点类型。另一个是重构表达式以使幻像溢出成为不可能。让我们尝试用我们的表达式来做后者。
算术定律告诉我们,以下公式应该产生相同的结果:
(x * 3) / 100
(3 * x) / 100
(x / 100) * 3
(3 / 100) * x
但是,Solidity 中的整数除法与纯数学中的除法不同,因为在 Solidity 中它将结果四舍五入为零。前两个变体基本上是等价的,并且都存在幻象溢出。第三种变体没有幻象溢出问题,但不太精确,尤其是对于小x。第四个变体更有趣,因为它会令人惊讶地导致编译错误:
browser/Junk.sol:5:18: TypeError: Operator * not compatible with types rational_const 3 / 10 and uint256
browser/Junk.sol:5:18: TypeError: Operator * not compatible with types rational_const 3 / 10 and uint256
我们已经在上一篇文章中中描述了这种行为。为了使第四个表达式编译,我们需要像这样更改它:
(uint (3) / 100) * x
然而,这并没有多大帮助,因为更正后的表达式的结果始终为零,因为3 / 100
向零四舍五入就是零。
通过第三个变体,我们设法以精度为代价解决了幻影溢出问题。实际上,精度损失仅对小x显着,而对于大x则可以忽略不计。请记住,对于原始表达式,只有大x才会出现幻像溢出问题,因此我们似乎可以像这样组合这两种变体:
x > SOME_LARGE_NUMBER ? x / 100 * 3 : x * 3 / 100
这里SOME_LARGE_NUMBER
可以计算为 (2²⁵⁶-1)/3 并将该值向下舍入。现在对于小x我们使用原始公式,而对于大x我们使用不允许幻像溢出的修改公式。看起来我们现在解决了幻像溢出问题,而没有显着降低精度。干得好,对吧?
在这种特殊情况下,可能是的。但是,如果我们需要计算的不是 3%,而是 3.1415926535% 怎么办?公式为:
x > SOME_LARGE_NUMBER ?
x / 1000000000000 * 31415926535 :
x * 31415926535 / 1000000000000
我们的SOME_LARGE_NUMBER
意愿变为 (2²⁵⁶-1)/31415926535。那时没有那么大。那么 3.141592653589793238462643383279% 呢?这种方法适用于简单的情况,但似乎不能很好地扩展。
EVM 不提供溢出保护操作码。Solidity 在编译器级别不提供任何溢出保护。因此,智能合约开发人员必须在代码级别解决溢出问题,这使得代码变得繁琐且 gas 效率较低。
幻象溢出更难检测和解决。直截了当会导致权衡取舍,而且通常无法扩展。
在我们的下一篇文章中,我们将提出解决幻影溢出问题的更好方法