智能合约(smart contract)正在变为未来十年最重要的编程语言。它的出现颠覆了近30年的中心化的编程方式,给整个社会带来公开、透明、无篡改、可信任的运行环境,但与此同时它也面临前所未有的技术挑战和防不甚防的安全漏洞。这些挑战和漏洞有的来自于设计本身,有的则来自于全新的、分布式的运行环境。本文将通过梳理以太坊智能合约历史上出现过或已知的安全漏洞给正在或即将要做智能合约的你一个警醒。
注:本文以Solidity作为智能合约的编程语言。
先看下面两段代码:
address addr = 0x6c8f2a135f6ed072de4503bd7c4999a1a17f824b; if(!addr.call.value(20 ether)()){ throw; }
以及:
address addr = 0x6c8f2a135f6ed072de4503bd7c4999a1a17f824b; if(!addr.send(20 ether)){ throw; }
这两段代码都是向0x6c8f…的合约地址发送20个ether,第二段代码没有漏洞,而第一段代码却存在严重的安全漏洞。为什么?
我们先来看一下addr.call.value()()(注意:是两个括号,第一个括号是对要转移多少以太币的赋值,第二个括号是方法的调用)和addr.send()的区别。两者都是向某个地址发送以太币,都是一个新的message call,不同的是这两个调用的gaslimit不一样。send()给予0的gas(相当于call.gas(0).value()()),而call.value()()给予全部(当前剩余)的gas。
注:对于需要调用fallback函数又没有给予任何gas的情况,EVM将自动把gas调整为不超过2300。
当我们调用某个智能合约时,如果指定的函数找不到,或者根本就没指定调用哪个函数(如发送ether)时,fallback函数就会被调用。
fallback函数被设计成不能做太多事,合理的做法是在fallback函数里打印一些log(或称event),以通知客户端(通常是web3.js)一些相关信息。所以你如果给该调用不发送任何气(就像send()一样),系统会默认给予2300气的上限来执行fallback函数。
但是当你通过addr.call.value()()的方式发送ether,情况就不一样了。和send()一样,fallback函数会被调用,但是传递给fallback函数可用的气是当前剩余的所有气(可能会是很多),这时fallback函数可以做的事情就有很多(如写storage、再次调用新的智能合约等等)。一个精心设计的用于做恶的fallback可以做出很多危害系统的事情。
所以避开这个安全漏洞,结论就是:总是用send()来发送ether,而不是用call.value()。
看下面的代码:
function withdrawBalance() { amountToWithdraw = userBalances[msg.sender]; if(amountToWithdraw > 0){ if (!(msg.sender.call.value(amountToWithdraw)())) { throw; } userBalances[msg.sender] = 0; } }
这是一段给用户取款的代码,让用户一次性从你的智能合约里取回存款。例如,你的合约账户共有1000个ether,而某用户存有10个ether。因为该代码存有严重的递归调用漏洞,该用户可轻松地将你账户里的1000个ether全部提走。
首先,该段代码使用了addr.call.value()()来发送ether,而不是send(),给fallback函数的调用提供了足够多的gas。你只要将fallback函数写成如下的方式便可取走所有的ether:
function () { address addr = 0x6c8f2a135f6ed072de4503bd7c4999a1a17f824b; if(COUNT<100){ addr.call("withdrawBalance"); COUNT++; } }
在这段fallback代码中,当计数器小于100时,递归调用withdrawBalance函数。在这种情况下,
msg.sender.call.value(amountToWithdraw)()
将被调用100次,从而取走100*10 ether。
所以在写智能合约时,需要考虑到它可能被递归调用,在这个case里,我们可以这样调整代码以防止递归调用而出现的问题:
function withdrawBalance() { amountToWithdraw = userBalances[msg.sender]; userBalances[msg.sender] = 0; if(amountToWithdraw > 0){ if (!(msg.sender.call.value(amountToWithdraw)())) { userBalances[msg.sender] = amountToWithdraw; throw; } } }
调用深度(call depth)被限制为1024。EVM中一个智能合约可以通过message call调用其它智能合约,被调用的智能合约可以继续通过message call再调用其它合约,甚至是再调用回来(recursive)。嵌套调用的深度被限定为1024。
看下面这段代码:
function sendether(){ address addr = 0x6c8f2a135f6ed072de4503bd7c4999a1a17f824b; addr.send(20 ether); //you think the send should return true var thesendok = true; //do something regarding send returns ok ... }
并且对方的fallback函数定义为:
function(){ //do nothing }
你认为你的代码肯定是安全的,因为对方已经明确定义了fallback方法。但是你错了,攻击者只需要制造出1023个嵌套调用,然后再调用sendether(),就可以让add.send(20 ether)失败,而其它执行成功。代码如下:
function hack(){ var count = 0; while(count < 1023){ this.hack();//this keyword makes it a message call count++; } if(count==1023){ thecallingaddr.call("sendether"); } }
所以为了解决深度限制的问题,正确的写法应该是在每次涉及到call depth增加的地方都检查调用返回是否正确,如下:
function sendether(){ address addr = 0x6c8f2a135f6ed072de4503bd7c4999a1a17f824b; if(!addr.send(20 ether)){ throw; //somebody hacks me } //you think the send should return true var thesendok = true; //do something regarding send returns ok ... }
了解了智能合约里面的这三个基本的漏洞,下面我们来看看在著名的The DAO事件中,黑客是怎么利用它们来窃取以太币,造成以太坊发展史上影响最为惨重的一次事件。
The DAO的漏洞是上面第一二个漏洞的组合。看下面的代码:
function splitDAO( uint _proposalID, address _newCurator)noEther onlyTokenholders returns (bool _success){ ... uint fundsToBeMoved = (balances[msg.sender] * p.splitData[0].splitBalance) / p.splitData[0].totalSupply; if(p.splitData[0].newDAO.createTokenProxy.value(fundsToBeMoved)(msg.sender) == false) throw; ... withdrawRewardFor(msg.sender); totalSupply -= balances[msg.sender]; balances[msg.sender] = 0; paidOut[msg.sender] = 0; return true; }
黑客通过将下面的代码调用多次,以转移多份以太币:
p.splitData[0].newDAO.createTokenProxy.value(fundsToBeMoved)(msg.sender)
他是怎么做到的呢?很简单!当合约执行到:
withdrawRewardFor(msg.sender);
时候,会进入相应的函数:
function withdrawRewardFor(address _account) noEther internal returns (bool _success){ ... if(!rewardAccount.payOut(_account,reward)) //漏洞代码 throw; ... }
payOut函数定义如下:
function payOut(address _recipient, uint _amount) returns (bool){ ... if(_recipient.call.value(_amount)) //漏洞代码 PayOut(_recipient, _amount); return true; }else{ return false; } }
看到这里,你也许已经懂了,这和我们前面给出的例子几乎是一模一样的。代码中通过addr.call.value()()的方式发送以太币,而不是send(),这给黑客留下了空间。黑客只需要制造出一个fallback函数,在该函数里再次调用splitDAO()即可。
也许你在想,如果你早几个月看到此篇文章,也可以成为The DAO的那个黑客,转移走价值上百万美元的以太币。实际上,The DAO的漏洞在代码层面显而易见,因此在The DAO实施之前已经在网上被人发现并给予警示,只是并没有得到重视,直到攻击真正发生!
The DAO事件给整个以太坊社区带来了重大影响,智能合约的安全成了为最为重要且紧迫的事情。只有让大家都能了解智能合约编程各种可能的安全漏洞才能让它在未来越来越安全!
声明:此文出于传递更多信息之目的,并不意味着赞同其观点或证实其描述。本网站所提供的信息,只供参考之用。