智能合约安全:自毁函数攻击

自毁函数是由以太坊虚拟机 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 ...