当ether被送到合约后,要么执行fallback函数或者执行合约里指定的其他函数。但是在智能合约中有2种异常情况。在这两种异常情况下,不用执行合约里的任何代码就可以操作合约里的ether。智能合约编程时,如果认为只有执行合约代码才能操作ether的话,就会导致合约收到攻击:ether可以被强制性的送到一个指定的合约。
攻击原理
在程序设计中,有一种通用的技术,即预先设置几个不变的状态,在相应的程序执行完后,再来确认状态没有发生变化。一个普通的例子就是ERC20 token标准里的totalSupply变量, 因为没有函数会去修改totalSupply,开发程序员可以在transfer()里加上一个检查:在确信totalSupply不变的前提下,保证程序的正常执行。
有一个不变的状态,特别容易被开发程序员用到,但是也特别容易被外部用户操纵。这个状态就是当前合约里的ether数量。通常,作为刚刚接触Solidity的程序员,往往会有一个迷思:只有合约里payable的函数才能发送和接受ether。这个迷思导致一个虚假,实际上是不正确的关于合约中ether数量的想定。最明显的带有漏洞的用法就是this.balance。像下面所显示的,对this.balance不当使用会导致很严重的漏洞。
在两种情况下,ether会被强制性的送给一个合约,既不会用到payable 修饰符也不会执行任何合约代码。下面我们具体讨论。
异常情况1:自毁/自杀(Self Destruct / Suicide)
智能合约可以实现selfdestruct(address)函数,这个函数会删除合约地址里的所有二进制代码并且把合约里所有的ether送到一个可以指定参数的地址。如果指定的地址也是一个智能合约的话,就不会调用合约里任何的函数(包含fallback) 因而, selfdestruct()函数能被用来强制性的发送ether到任何合约,而不会执行合约里的任何代码:合约里的任何payable 函数都不会执行。这意味着:攻击者可以创建一个带有selfdestruct()的合约,同时发送ether给这个合约,调用selfdestruct(target),然后在强制性的发送ether给一个指定的合约。这篇文章有很详细的描述:
异常情况2:Pre-sent Ether
第二种方法是预导入(pre-load)带有ether的合约。合约地址是确定的:合约地址是基于创建合约地址的哈希以及创建合约的交易的nonce两个信息计算而来。
address = sha3(rlp.encode([account_address,transaction_nonce]))
这意味着,任何人都可以在合约被创建前计算合约的地址,并发送ether给那个地址。然后当合约真正被创建是,合约就会有一个非零的ether余额
我们可以通过下面一个简单的合约代码来看看如何利用上面的知识来找到合约里的漏洞。
EtherGame.sol
contract EtherGame { uint public payoutMileStone1 = 3 ether; uint public mileStone1Reward = 2 ether; uint public payoutMileStone2 = 5 ether; uint public mileStone2Reward = 3 ether; uint public finalMileStone = 10 ether; uint public finalReward = 5 ether; mapping(address => uint) redeemableEther; // users pay 0.5 ether. At specific milestones, credit their accounts function play() public payable { require(msg.value == 0.5 ether); // each play is 0.5 ether uint currentBalance = this.balance + msg.value; // ensure no players after the game as finished require(currentBalance <= finalMileStone); // if at a milestone credit the players account if (currentBalance == payoutMileStone1) { redeemableEther[msg.sender] += mileStone1Reward; } else if (currentBalance == payoutMileStone2) { redeemableEther[msg.sender] += mileStone2Reward; } else if (currentBalance == finalMileStone ) { redeemableEther[msg.sender] += finalReward; } return; } function claimReward() public { // ensure the game is complete require(this.balance == finalMileStone); // ensure there is a reward to give require(redeemableEther[msg.sender] > 0); redeemableEther[msg.sender] = 0; msg.sender.transfer(redeemableEther[msg.sender]); } }
这个智能合约是一个简单的游戏:玩家发送0.5给合约,并争取成为第一个完成3个里程碑任务的人。里程碑任务是以ether标价的:完成第一个里程碑任务的第一个人(比如合约总额达到5 ether时的第一人),就会在游戏结束后拿回一部分的ether。当最终的里程碑任务(比如合约总额达到10ether时)达成时,游戏结束,相应的玩家拿到奖赏。
问题在于合约里这几行代码:
…uint currentBalance = this.balance + msg.value; // ensure no players after the game as finished require(currentBalance <= finalMileStone); … require(this.balance == finalMileStone); …
攻击者可以通过selfdestruct()来强制性的送小额的ether(比如0.1个ether)给合约,这样的话,所有将来的玩家都将不能达成里程碑任务。因为所有正规的玩家都只会发送0.5或者0.5倍数的ether。一旦合约接受了上面的0.1ether,this。balance将永远不会为0.5的倍数。所以下列的if条件永远都不会是true
…// if at a milestone credit the players account if (currentBalance == payoutMileStone1) { redeemableEther[msg.sender] += mileStone1Reward; } else if (currentBalance == payoutMileStone2) { redeemableEther[msg.sender] += mileStone2Reward; } else if (currentBalance == finalMileStone ) { redeemableEther[msg.sender] += finalReward; } …
更要命的是,如果一个报复心很强的攻击者,如果错过了一个里程碑任务的话,他可以强制性的送10 ether (或者让合约余额超过finalMileStone数目的ether),这就将永远锁住合约里的所有奖励。这是因为claimReward()函数会因为下面的require语句总是回退(revert) (this.balance 总是大于 finalMileStone).
// ensure the game is complete require(this.balance == finalMileStone);