0x01 溢出攻击事件
2018年4月22日,黑客对BEC智能合约发起攻击,凭空取出:
57,896,044,618,658,100,000,000,000,000,000,000,000,000,000,000,000,000,000,000.792003956564819968 个BEC代币并在市场上进行抛售,BEC随即急剧贬值,价值几乎为0,该市场瞬间土崩瓦解。
2018年4月25日,SMT项目方发现其交易存在异常,黑客利用其函数漏洞创造了:
65,133,050,195,990,400,000,000,000,000,000,000,000,000,000,000,000,000,000,000+50,659,039,041,325,800,000,000,000,000,000,000,000,000,000,000,000,000,000,000 的SMT币,火币Pro随即暂停了所有币种的充值提取业务。
2018年12月27日,以太坊智能合约Fountain(FNT)出现整数溢出漏洞,黑客利用其函数漏洞创造了:
2+115792089237316195423570985008687907853269984665640564039457584007913129639935 的SMT币。
历史的血泪教训,如今不该再次出现。让我们一起缅怀这些一夜归零的代币,吸取前人经验教训。
0x02 整数溢出简介
- 整数溢出原理
由于计算机底层是二进制,任何十进制数字都会被编码到二进制。溢出会丢弃最高位,导致数值不正确。
如:八位无符号整数类型的最大值是 255,翻译到二进制是 1111 1111;当再加一时,当前所有的 1 都会变成 0,并向上进位。但由于该整数类型所能容纳的位置已经全部是 1 了,再向上进位,最高位会被丢弃,于是二进制就变成了 0000 0000
注:有符号的整数类型,其二进制最高位代表正负。所以该类型的正数溢出会变成负数,而不是零。
- 整数溢出示例(通用编程语言)
编程语言由算数导致的整数溢出漏洞司空见惯,其类型包括如下三种:
• 加法溢出
• 减法溢出
• 乘法溢出
我们先以运行在 JVM 上的 Kotlin 编程语言做加法运算来测试整数溢出为例:
fun main() {
println(Long.MAX_VALUE + 1) // Long 是有符号的 128 位 Integer 类型
}
程序会打印出 -9223372036854775808,这其实是在编译期就没有防止整数溢出,因为编译器让溢出的代码通过编译了。
当然,也有在编译期严格检查整数溢出的编程语言。如区块链世界最火的 Rust 编程语言:
fn main() {
dbg!(u128::MAX + 1); // u128 是无符号的 128 位 Integer 类型
}
编译这段代码,你会得到编译错误:
error: this arithmetic operation will overflow
--> src/main.rs:2:10
|
2 | dbg!(u128::MAX + 1);
| ^^^^^^^^^^^^^ attempt to compute `u128::MAX + 1_u128`, which would overflow
|
= note: `#[deny(arithmetic_overflow)]` on by default
很好,这有效阻止了编译期溢出的问题。那么,如果是运行时呢?我们来读取用户输入试试:
fn main() {
let mut s = String::new();
std::io::stdin().read_line(&mut s).unwrap();
dbg!(s.trim_end().parse::<u8>().unwrap() + 1); // u8 是无符号的 8 位 Integer 类型
}
运行 cargo r,输入:255,得到 panic:
thread 'main' panicked at 'attempt to add with overflow'
可以看到,在 debug 模式下,溢出会直接 panic,也就是:程序崩溃掉、停止工作。那么,release 模式下也是这样吗?
运行 cargo r —release,输入:255,打印:
[src/main.rs:4] s.trim_end().parse::<u8>().unwrap() + 1 = 0
综上,我们得到一条结论:即使在编译期严格检查溢出的程序语言,依然会有整数溢出问题。整数溢出就好像是一个魔咒,总会隔三岔五地出现,无法一劳永逸地消除。
- 智能合约中的整数溢出(Solidity 语言)
在区块链的世界里,智能合约的 Solidity 语言中,对于 0.8.0 以下的版本,也存在整数溢出问题。
和通用型编程语言一样,我们先看看编译期是否会发生溢出:
实测,测试函数会直接发生编译错误。再来看看运行时:
实测,程序会在运行时溢出。我们建议使用 SafeMath 库来解决漏洞溢出:
library SafeMath {
function mul(uint256 a, uint256 b) internal constant returns (uint256) {
uint256 c = a * b;
assert(a == 0 || c / a == b);
return c;
}
function div(uint256 a, uint256 b) internal constant returns (uint256) {
uint256 c = a / b;
return c;
}
function sub(uint256 a, uint256 b) internal constant returns (uint256) {
assert(b <= a);
return a - b;
}
function add(uint256 a, uint256 b) internal constant returns (uint256) {
uint256 c = a + b;
assert(c >= a);
return c;
}
}
对于 Solidity 0.8.0 以上的版本,官方已经修复了这个问题。那么它到底是如何修复的?将要溢出时会发生什么来防止溢出呢?
实测,Solidity 0.8 以上的版本发生运行时溢出会直接 revert。
原来,修复的方式就是不允许溢出。int256 足够大,只要保证无法被黑客利用这一点凭空创造收益,我们就成功了。
0x03 漏洞合约、攻击手法
以 BEC 合约为例,合约地址为:
0xC5d105E63711398aF9bbff092d4B6769C82F793D
在 etherscan 上的地址为:
https://etherscan.io/address/0xc5d105e63711398af9bbff092d4b6769c82f793d#code
存在溢出漏洞的合约代码如下:
function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool) {
uint cnt = _receivers.length;
uint256 amount = uint256(cnt) * _value; //溢出点,这里存在整数溢出
require(cnt > 0 && cnt <= 20);
require(_value > 0 && balances[msg.sender] >= amount);
balances[msg.sender] = balances[msg.sender].sub(amount);
for (uint i = 0; i < cnt; i++) {
balances[_receivers[i]] = balances[_receivers[i]].add(_value);
Transfer(msg.sender, _receivers[i], _value);
}
return true;
}
当时的合约版本是 ^0.4.16,小于 0.8 版本,也没有使用 SafeMath 库,因此存在整数溢出问题。
黑客传入了一个极大的值(这里为2**255),通过乘法向上溢出,使得 amount(要转的总币数)溢出后变为一个很小的数字或者0(这里变成0),从而绕过 balances[msg.sender] >= amount 的检查代码,使得巨大 _value 数额的恶意转账得以成功。
实际攻击的恶意转账记录:
https://etherscan.io/tx/0xad89ff16fd1ebe3a0a7cf4ed282302c06626c1af33221ebe0d3a470aba4a660f
0x04 总结
在 Solidity 0.8 版本以下,且未使用 SafeMath 库的情况下:黑客往往会利用溢出构造一个极小值/极大值,从而绕过某些检查,使巨额恶意转账得以成功。
当然,合约漏洞不仅仅只有整数溢出。除了开发者自身提高安全开发意识外,寻找专业的安全团队对合约进行全面的审计也是非常有必要的。