BEC合约整数溢出漏洞还原与分析
一、币圈一秒,人间一年
有道是币圈一日,人间一年。这个说法又得升级了,叫币圈一秒,人间一年。
前不久,币圈又出大事啦。BEC智能合约被爆出整数溢出漏洞,导致黑客能无限印币,在一次交易中,也就那么几秒钟的事情,黑客就“无中生有”地给两个账户转了天文数字般的BEC币,而原账户一分BEC币都没损失。大家来围观下这笔交易:
https://etherscan.io/tx/0xad89ff16fd1ebe3a0a7cf4ed282302c06626c1af33221ebe0d3a470aba4a660f
围观归围观,所谓外行看热闹、内行看门道。
看到这么大笔BEC币,想必大家已经流着口水。。。
别馋,虽然大熊不能教大家在现实中黑一把,但搭个测试网络,还原下漏洞,过过瘾也是可以的,顺便还能学习下智能合约部署、整数溢出漏洞原理。知识是无价的,说不定以后能靠这些知识大赚一笔。好吧,言归正传:
那么,什么是整数溢出呢?
二、整数溢出浅析
首先,什么是溢出?
往空杯里面加水,加到满之后,继续加水,水就会流出来,这就是溢出。然而需要注意的是:尽管溢出,但杯里面依然是满水的状态。
然后,什么是整数溢出?
跟满杯加水溢出的情况不同,整数溢出像水轮车。往里面加水,加满之后,继续加水,水轮车将会失衡并转动,此时水将会被全部倒出来,并重新盛水。这就跟整数溢出类似:当变量累加到超过自身容量范围时,将会溢出,归零。而黑客也是利用了这一点,把运算前的数值作为转账金额,把运算后溢出的0作为检测条件,从而实现绕过。
这次的BEC整数溢出漏洞就出在batchTransfer函数(功能是批量转账)里面,具体来说就是以下这行:
uint256 amount = uint256(cnt) * _value;
简单分析一下:比如张三给李四、王五转账,数额为2^255(2的255次方),那么cnt=2,_value=2^255,而将cnt和_value两者相乘,结果为2^256,刚好超过uint256的范围,溢出之后amount的结果为0,接下来所有条件都能通过,最终结果是张三账户减去0个BEC币,而李四、王五的账户增加2^255个BEC币。
三、环境搭建、漏洞还原与分析(无意中又发现了一个漏洞)
这个漏洞虽然不起眼,逻辑也很简单,但是直接带来的后果相当于无限印BEC币啊,这还了得,下面就开始搭建测试网络,也给自己印几批天文数字BEC币过过瘾。
由于区块链环境搭建、以太坊和BEC智能合约部署过程较长,且并不是本文重点,所以简单阐述,若有兴趣可到本人微信公众号《进击的大熊》,回复“BEC”可见。
(一)环境搭建
geth客户端安装,用来运行以太坊节点、创建和管理账户、发送交易、挖矿、部署智能合约。
具体环境是:Win10 64位、geth-windows-amd64-1.8.6
(二)漏洞还原
1、准备创世块文件、初始化创世块,并启动区块链节点
2、创建3个用户,并分别命名为zhangsan、lisi、wangwu
3、主节点zhangsan挖矿,得到一些ETH,后面部署智能合约等操作都需要ETH。
4、复制BEC智能合约源码,用在线Remix工具编译,并部署到虚拟网络,下图可以看到已经部署成功。
5、溢出转账
这里无意中又发现了一个漏洞,不过不是BEC合约,而是console,先前我一直这样来转账:(绿色数值就是2^255)
>bectoken.batchTransfer([lisi,wangwu],57896044618658097711785492504343953926634992332820282019728792003956564819968,{from:zhangsan})
然而失败了若干次,由于数值较大,每次都怀疑是不是复制错了,为了解决这个问题,还试过:
>bectoken.batchTransfer([lisi,wangwu],Math.pow(2,255),{from:zhangsan})
但还是失败,李四和王五的账号依旧为0.
后来用Remix调试才发现,原来一直不能成功绕过检测(require(_value > 0 && balances[msg.sender] >= amount); ),因为amount根本不为0,张三的账户上也没有那么多BEC币。进一步发现,传进去的_value也不是2^255,而是诡异的0x80000000000000016c889……
怪不得不能通过 balances[msg.sender] >= amount
那么,换个思路吧,直接把_value的值在源代码设为2^255则可实现绕过,并成功实现转账,最少证明了理论是可行,大方向没错。
这种情况下,我想只能是console传递数值的时候出现问题了,但找了很多资料没能解决,最后在Github上发帖咨询Geth团队,才知道原来也是个溢出漏洞,真是“洞中洞”啊。
发帖没过多久就收到了回复,感谢这位小哥!
大概意思是:所用的控制台基于JavaScript,而JavaScipt使用float-s来表示数字,直接传递2^255这样大的数值,将会产生溢出,从而使数值发生变化。解决方法是,需要使用一个大数字库,这样才能使用任意精度的整数。
因此我改变打法,用web3.toBigNumber来表示2^255,最终也实现了还原,成功给李四、王五账户上转了天文数字般的BEC币。
var value = web3.toBigNumber(’57896044618658097711785492504343953926634992332820282019728792003956564819968′);
另外,可能眼尖的朋友会留意到,为什么zhangsan账户上也有一笔BEC,那是因为智能合约上规定,创造者会拥有一些初始的Token,请见怪不怪。
(三)安全代码的作用
已经证明了整数溢出的危害性,那么该如何解决这个问题呢?其实BEC智能合约里面已经回答了这个问题,可以发现除了漏洞那行代码之外,其它运算都用了安全运算库里面的函数,那么试试吧。
更改BEC智能合约代码,把*改成库里面的mul函数,再重复上面的过程。
可以看到,这时已不能绕过检测,转账失败。
分析下这个Mul函数,代入具体数值说明一下:
假如:a = 2,b = 2^255
那么:c = a * b = 0
但: c / a = 0 != b
因此也就绕不过安全运算函数了。
四、整数溢出的原因
水轮车溢出之后归零,并重新开始盛水,是因为自身失衡所致。
那整数溢出的原理又是什么呢,当uint256的参数为2^256时,为什么会等于0,而不是继续等于2^256-1。
下面尝试用C++程序实验来解释:
从下图实验结果可以发现,当int参数赋给unsigned short参数之后,unsigned short只取后16的数值,而前面的数值一概不理,所以就会出现当输入为65536,赋给输出值后,产生溢出,只保留最后的0000,最终输出值为零。
另外,当输入为65537,-1时,按照溢出的规则,则输出也就自然为1、65535。
用OD调试这个C++程序,以进一步说明:
单步调试来到这里输入数值前,可以从堆栈发现输入值将会保存到 地址为0x004FF918的内存空间中。
于是输入65535,可以发现0x FFFF(65535)已经被存储到 0x004FF918的内存空间中。
接下来,程序会将该值用movzx指令赋给ecx寄存器,以实现C++中的output=input。
继续单步调试,可以看到ecx寄存器的寄存器值为0x FFFF,也就是说,当输入为65535,输出也为65535。
再来一次,这次输入65536,可以发现0x 0001 0000已经被存储到 地址为0x00CFF8CC的内存空间中。
接下来,程序会将该值用movzx指令赋给ecx寄存器。
单步调试,继续运行程序,可以看到ecx寄存器的寄存器值为0x 0000。也就是说当输入为65536时,输出为0。
所以关键在于:MOVZX指令。
这是汇编语言数据传送指令MOV的变体,无符号扩展并传送。
简单来说,movzx将源操作数取出,置于目的操作数,而目的操作数其余位用0填充。于是就出现了溢出时,只留下低位的16位数值。
五、写在最后
BEC智能合约也不是小众产品,但都能出现如此明显且致命的漏洞,可见币圈安全问题真的不会少。
想必大家都想着产品早日上线,早日盈利,所以基本的代码审计、系统测试这类基本的安全检查都能省则省,或者简单带过,带来的后果可想而知。这次的安全事件给区块链开发者和黑客们都提了醒,开发者得加大精力考虑代码安全,而黑客们则是将注意力从持币者身上转移到源码审计。
信息安全问题,归根结底是人与人之间的利益博弈,只要利益存在,攻与防、矛与盾就会一直持续。
希望这次的事件,能引起大家对信息安全的重视。
时刻牢记安全无小事,防微杜渐是关键!