智能合约学习笔记——自毁函数攻击复现

关于自毁函数

首先了解 solidity 中能够转账的操作都有哪些:

  1. transfer:转账出错会抛出异常后面代码不执行;
  2. send:转账出错不会抛出异常只返回 true/false 后面代码继续执行;
  3. call.value().gas()():转账出错不会抛出异常只返回 true/false 后面代码继续执行,且使用 call 函数进行转账容易发生重入攻击。

上面三种都需要目标接收转账才能成功将代币转入目标地址
但是,有一种不需要接受就能给合约转账的函数:自毁函数。

自毁函数:

自毁函数是由以太坊虚拟机 EVM 提供的一项功能,用于销毁区块链上部署的智能合约。当合约执行自毁操作时,合约账户上剩余的以太币会发送给指定的目标,然后其存储和代码从以太坊状态中被移除。

然而,自毁函数也是一把双刃剑,一方面它可以使开发人员能够从以太坊中删除智能合约并在紧急情况下转移以太币。另一方面自毁函数也可能成为攻击者的利用工具,攻击者可以利用该函数向目标合约“强制转账”从而影响目标合约的正常功能(比如开发者使用 address(this).balance 来取合约中的代币余额就可能会被攻击)。

自毁函数在 solidity 中定义为 selfdestruct。也就是说,智能合约在某些特殊情况下可以自行毁灭,比如:存在致命bug、废弃不再使用等。

EtherGame合约示例

“幸运七”核心是一个叫做 EtherGame 的合约。玩家们每次向 EtherGame 合约中打入一个以太,第七个成功打入以太的玩家将成为赢家,获得合约中累积的七个以太,其它玩家都是输家,分文不得。

自毁函数攻击“幸运七”的最终结果,是导致“幸运七”游戏停止服务,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;
   }
}

remix编译部署
在这里插入图片描述
换一个账户通过deposit存入以太
在这里插入图片描述这里每次只能存入一个以太,多了少了都可以
getBalance可以查看余额
在这里插入图片描述
winner里存的是赢家的地址,当没有赢家时默认为0,当余额变成7时,地址为存入第七个以太的账户地址
在这里插入图片描述
这个时候是不能再存入以太了,要等赢家将奖品取出才行
在这里插入图片描述
赢家取奖品的时候直接用赢家账户点claimReward函数即可,合约回自行判断是不是赢家。赢家取走后游戏恢复一开始的状态
在这里插入图片描述

自毁函数攻击

我们可以已经知道,当合约执行自毁操作时,合约账户上剩余的以太币会发送给指定的目标,我们可以构造一个攻击合约,然后触发 selfdestruct 函数让攻击合约自毁,攻击合约中的以太就会发送给目标合约。这样我们就可以一次向 EtherGame 合约中打入多枚以太,而不通过 EtherGame.deposit 函数,从而完成攻击。

举个例子:在极端情况下,如果已经有六个玩家参与了游戏且成功向合约中各自打入了 1 个以太,此时合约中有 6 枚以太,这样我们只需要用 selfdestruct 强制打入一枚以太,而不走 EtherGame.deposit 的逻辑,就会导致 EtherGame 合约记账错误, 从而导致合约瘫痪(DoS),就会造成合约中的 6 枚以太无法取出,因为此时还没有诞生出 winner。

本次攻击之所以成功,是因为开发者过份相信自己能够控制账户余额,却不知道以太坊留有后门,可以通过自毁函数强制转账。

// 攻击合约
contract Attack { 
   EtherGame etherGame; 
   constructor(EtherGame _etherGame) { 
      etherGame = EtherGame(_etherGame); 
   } 
   function attack() public payable { 
      address payable addr = payable(address(etherGame)); 
      selfdestruct(addr); 
   }
}

编译部署
注意,这个合约部署的时候需要地址参数,即先将要攻击的地址传进去才可以部署,当然你也可以对这个合约进行简单修改让他部署的时候不需要参数,攻击函数才需要参数
在这里插入图片描述
在这里插入图片描述
用这个函数传以太的话可以不经过deposit函数,所以可以一次性传入多个以太

攻击模拟

假设此时原合约里已经有6个以太了
在这里插入图片描述
然后用攻击函数传一个以太进去
发现被攻击合约里现在有七个以太,但是赢家的地址依旧是0
在这里插入图片描述
这个时候在用这个合约,发现既没有赢家产生,其他玩家也不能继续玩游戏往里面存以太,这里面的7个以太也没有正确地址可以取出来

然后这里不一定就是6个的时候才能被攻击,其他的时候也可以,比如原本里面有5个,攻击合约可以一下传入2个以太来让这个合约失效,当然这个时候也可以传入3个以太,那就好出现
在这里插入图片描述

这个时候你或许会想问一个问题,那就是,我们一开始说了,自毁函数是用来强制转账,那为什么不可以将这个幸运七游戏里的以太强制转入自己的账户呢?这是因为区块链上的合约是不可更改的,不可能该幸运七游戏的合约,我们用自毁函数毁的是自己的攻击合约,这也是为什么我们的攻击合约部署之后只能运行一次,因为这个合约运行之后就会自毁,然后把自己余额中的所有以太强制存入幸运七游戏中,这也是可以用自毁函数攻击幸运七的原因之一。

修复方案

上面说了,本次攻击之所以成功,是因为开发者过份相信自己能够控制账户余额,却不知道以太坊留有后门,可以通过自毁函数强制转账。

所以解决自毁函数攻击的关键,就是不要以账户余额做为判断条件。 我们可以定义一个状态变量,用来记录游戏资金的余额,这个变量是开发者可以自行掌控的。

就上面的漏洞合约 EtherGame 来说,作为开发者,这个合约可以被攻击者攻击是因为依赖了 address(this).balance 来获取合约中的余额且这个值可以影响业务逻辑,所以我们这里可以设置一个变量 balance,只有玩家通过 EtherGame.deposit 成功向合约打入以太后 balance 才会增加。这样只要不是通过正常途径进来的以太都不会影响我们的 balance 了,避免强制转账导致的记账错误。

作为审计者,我们需要结合真实的业务逻辑来查看 address(this).balance 的使用是否会影响合约的正常逻辑,如果会影响那我们就可以初步认为这个合约存在被攻击者强制打入非预期的资金从而影响正常业务逻辑的可能(比如被 selfdestruct 攻击)。在审计过程中还需要结合实际的代码逻辑来进行分析。

修复代码

// 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;
   }
}
参考

知识点参考1
知识点参考2

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值