When the WBTC-WETH pool (which is used as the collateral for USDS) has somewhat low liquidity, an attacker can bypass the arbitrage rebalancing logic to inflate the value of their LP tokens using a flashloan to mint an excessive amount of USDS (at no cost). They can then dump this USDS on the market, completely destroying the stablecoin.
Proof of Concept
Consider the following starting state: 1e8 WBTC = 30k USD, and 1e18 WETH = 2k USD. Let's assume the WBTC-WETH collateral pool has 1e7 WBTC and 15e17 WETH, meaning there is a total of 6k USD in assets. Let's assume there is 1e18 LP tokens outstanding for this pool, in which the attacker has only 1%, or 1e16 LP tokens (60 USD).
Generally when minting USDS with a collateralization ratio of 200%, this means that the attacker can mint only 30e18 USDS. Now lets see how the attacker can circumvent this & mint an excessively large amount of USDS.
Step (1):
Attacker creates a smart contract to perform the following attack. They first flashloan borrow (15e22-15e17) WETH from Balancer, or another platform with 0 fees. With this they call Pools:depositSwapWithdraw. This triggers the internal call Pools:_adjustReservesForSwapAndAttemptArbitrage which is defined as follows:
function _adjustReservesForSwapAndAttemptArbitrage( IERC20 swapTokenIn, IERC20 swapTokenOut, uint256 swapAmountIn, uint256 minAmountOut ) internal returns (uint256 swapAmountOut)
{
// Place the user swap first
@> swapAmountOut = _adjustReservesForSwap( swapTokenIn, swapTokenOut, swapAmountIn );
...
@> uint256 arbitrageProfit = _attemptArbitrage( swapTokenIn, swapTokenOut, swapAmountIn );
emit SwapAndArbitrage(msg.sender, swapTokenIn, swapTokenOut, swapAmountIn, swapAmountOut, arbitrageProfit);
}
This calls _adjustReservesForSwap which (condensed) runs the following logic:
Here reserve1 is WETH, which we said earlier was 15e17. reserve0 is WBTC, which we said earlier is 1e7. The attacker uses the (15e22-15e17) flashloaned WETH to swap into WBTC. The following output is calculated:
Next in the _adjustReservesForSwapAndAttemptArbitrage function call, _attemptArbitrage is called, which is defined as follows:
function _attemptArbitrage( IERC20 swapTokenIn, IERC20 swapTokenOut, uint256 swapAmountIn ) internal returns (uint256 arbitrageProfit )
{
// Determine the value of swapTokenIn (in WETH) that the user has specified as it will impact the arbitrage trade size
uint256 swapAmountInValueInETH = _determineSwapAmountInValueInETH(swapTokenIn, swapAmountIn);
if ( swapAmountInValueInETH == 0 )
return 0;
// Determine the arbitrage path for the given user swap.
// Arbitrage path returned as: weth->arbToken2->arbToken3->weth
@> (IERC20 arbToken2, IERC20 arbToken3) = _arbitragePath( swapTokenIn, swapTokenOut );
(uint256 reservesA0, uint256 reservesA1) = getPoolReserves( weth, arbToken2);
(uint256 reservesB0, uint256 reservesB1) = getPoolReserves( arbToken2, arbToken3);
@> (uint256 reservesC0, uint256 reservesC1) = getPoolReserves( arbToken3, weth);
// Determine the best amount of WETH to start the arbitrage with
@> uint256 arbitrageAmountIn = _bisectionSearch(swapAmountInValueInETH, reservesA0, reservesA1, reservesB0, reservesB1, reservesC0, reservesC1 );
// If arbitrage is viable, then perform it
@> if (arbitrageAmountIn > 0)
@> arbitrageProfit = _arbitrage(arbToken2, arbToken3, arbitrageAmountIn);
@> }
Importantly, since this swap was from WETH->WBTC, the arbitragePath returned from the _arbitragePath call will be WETH->SALT->WBTC->WETH, and that arbToken3 will be WBTC. This means that reservesC0 will reference the amount of WBTC in the WBTC-WETH pool, which is now 100.
Since reservesC0 is 100, this if-statement results in _bisectionSearch returning 0, which then means that the _arbitrage function is not called. Ultimately this means that the pool will remain imbalanced, as the arbitrage logic could not be run.
function userCollateralValueInUSD( address wallet ) public view returns (uint256)
{
uint256 userCollateralAmount = userShareForPool( wallet, collateralPoolID );
if ( userCollateralAmount == 0 )
return 0;
uint256 totalCollateralShares = totalShares[collateralPoolID];
// Determine how much collateral share the user currently has
(uint256 reservesWBTC, uint256 reservesWETH) = pools.getPoolReserves(wbtc, weth);
uint256 userWBTC = (reservesWBTC * userCollateralAmount ) / totalCollateralShares;
uint256 userWETH = (reservesWETH * userCollateralAmount ) / totalCollateralShares;
return underlyingTokenValueInUSD( userWBTC, userWETH );
}
Recall that the attacker has 1e17 LP tokens, while there are 1e18 outstanding. Here userWBTC = 100 * 1 / 100 = 1, and userWETH = 15e22 * 1 / 100 = 15e20. Since each 1e18 WETH is worth 2k, 15e20 WETH = 3_000_000 USD. With a collateralization ratio of 200%, this means the attacker can mint 1_500_000e18 USDS.
Step (3):
After minting the excessive USDS, the attacker will swap their 9999900 WBTC back through the WBTC-WETH pool, which will yield them exactly the same starting amount of WETH. They will then repay the flashloan, ending up with an excess of 1_500_000e18 USDS, using only a starting 60 USD of collateral.
Tools Used
Manual review
Recommended Mitigation Steps
Generally it is not considered good practice to price LP simply through the use of the spot token reserves for that pool. Consider an alternative approach such as this.
Lines of code
https://github.com/code-423n4/2024-01-salty/blob/main/src/stable/CollateralAndLiquidity.sol#L221-L236 https://github.com/code-423n4/2024-01-salty/blob/main/src/pools/Pools.sol#L409-L419 https://github.com/code-423n4/2024-01-salty/blob/main/src/pools/Pools.sol#L235-L276 https://github.com/code-423n4/2024-01-salty/blob/main/src/arbitrage/ArbitrageSearch.sol#L107-L108
Vulnerability details
Impact
When the WBTC-WETH pool (which is used as the collateral for USDS) has somewhat low liquidity, an attacker can bypass the arbitrage rebalancing logic to inflate the value of their LP tokens using a flashloan to mint an excessive amount of USDS (at no cost). They can then dump this USDS on the market, completely destroying the stablecoin.
Proof of Concept
Consider the following starting state: 1e8 WBTC = 30k USD, and 1e18 WETH = 2k USD. Let's assume the WBTC-WETH collateral pool has 1e7 WBTC and 15e17 WETH, meaning there is a total of 6k USD in assets. Let's assume there is 1e18 LP tokens outstanding for this pool, in which the attacker has only 1%, or 1e16 LP tokens (60 USD).
Generally when minting USDS with a collateralization ratio of 200%, this means that the attacker can mint only 30e18 USDS. Now lets see how the attacker can circumvent this & mint an excessively large amount of USDS.
Step (1): Attacker creates a smart contract to perform the following attack. They first flashloan borrow (15e22-15e17) WETH from Balancer, or another platform with 0 fees. With this they call
Pools:depositSwapWithdraw
. This triggers the internal callPools:_adjustReservesForSwapAndAttemptArbitrage
which is defined as follows:This calls
_adjustReservesForSwap
which (condensed) runs the following logic:Here reserve1 is WETH, which we said earlier was 15e17. reserve0 is WBTC, which we said earlier is 1e7. The attacker uses the (15e22-15e17) flashloaned WETH to swap into WBTC. The following output is calculated:
Note that reserve0 = 100 >= DUST, as DUST is 100.
Next in the
_adjustReservesForSwapAndAttemptArbitrage
function call,_attemptArbitrage
is called, which is defined as follows:Importantly, since this swap was from WETH->WBTC, the arbitragePath returned from the
_arbitragePath
call will be WETH->SALT->WBTC->WETH, and thatarbToken3
will be WBTC. This means thatreservesC0
will reference the amount of WBTC in the WBTC-WETH pool, which is now 100.ArbitrageSearch:_bisectionSearch
is then run, which has the following snippet of code:Since
reservesC0
is 100, this if-statement results in_bisectionSearch
returning 0, which then means that the_arbitrage
function is not called. Ultimately this means that the pool will remain imbalanced, as the arbitrage logic could not be run.Step (2): The attacker then calls
CollateralAndLiquidity:borrowUSDS
, in which they setamountBorrowed
equal to the output ofCollateralAndLiquidity:maxBorrowableUSDS
. This amount is ultimately based onCollateralAndLiquidity:userCollateralValueInUSD
, which is defined as follows:Recall that the attacker has 1e17 LP tokens, while there are 1e18 outstanding. Here
userWBTC
=100 * 1 / 100
= 1, anduserWETH
=15e22 * 1 / 100
= 15e20. Since each 1e18 WETH is worth 2k, 15e20 WETH = 3_000_000 USD. With a collateralization ratio of 200%, this means the attacker can mint 1_500_000e18 USDS.Step (3): After minting the excessive USDS, the attacker will swap their 9999900 WBTC back through the WBTC-WETH pool, which will yield them exactly the same starting amount of WETH. They will then repay the flashloan, ending up with an excess of 1_500_000e18 USDS, using only a starting 60 USD of collateral.
Tools Used
Manual review
Recommended Mitigation Steps
Generally it is not considered good practice to price LP simply through the use of the spot token reserves for that pool. Consider an alternative approach such as this.
Assessed type
Other