0xadrii - Multiplying the collateral amount by the `STANDARD_EXPANSION_FACTOR` when checking the required collateral is incorrect and allows borrowers to get undercollateralized loans #238
Multiplying the collateral amount by the STANDARD_EXPANSION_FACTOR when checking the required collateral is incorrect and allows borrowers to get undercollateralized loans
Summary
It is possible to obtain undercollateralized loans due to the fact that LenderCommitmentGroup_Smart checks the required collateral wrongly.
Vulnerability Detail
The LenderCommitmentGroup_Smart contract acts as a lending pool in TellerV2. Users can request loans and the LenderCommitmentGroup_Smart will act as the lender of those loans. However, these loans are constrained and must be overcollateralized.
This report describes a vulnerability related with scaling a price incorrectly and allowing borrowers to take undercollateralized loans. In order to understand it properly, we must first understand how LenderCommitmentGroup_Smart obtains the required collateral amount so that it can verify if the user is actually supplying the proper collateral amount.
In order to compute the required collateral, the LenderCommitmentGroup_Smart will make use of a uniswap v3 pool from the collateral and principal tokens. The required amount of collateral will then be given by the getCollateralRequiredForPrincipalAmount function:
// LenderCommitmentGroup_Smart.sol
function getCollateralRequiredForPrincipalAmount(uint256 _principalAmount)
public
view
returns (uint256)
{
uint256 baseAmount = _calculateCollateralTokensAmountEquivalentToPrincipalTokens(
_principalAmount
);
//this is an amount of collateral
return baseAmount.percent(collateralRatio);
}
This function internally calls the _calculateCollateralTokensAmountEquivalentToPrincipalTokens function, which will fetch and compute the price given by the uniswap pool:
It is important to understand the format in which the prices pairPriceWithTwap and pairPriceImmediate are returned. As we can see, prior to executing the _getCollateralTokensAmountEquivalentToPrincipalTokens function, the pair price is fetched by querying _getUniswapV3TokenPairPrice. This function will return the pair’s price in Uniswap’s X96 format (you can check more about this concept [here](https://blog.uniswap.org/uniswap-v3-math-primer)):
// LenderCommitmentGroup_Smart.sol
function _getUniswapV3TokenPairPrice(uint32 _twapInterval)
internal
view
returns (uint256)
{
// represents the square root of the price of token1 in terms of token0
uint160 sqrtPriceX96 = getSqrtTwapX96(_twapInterval);
//this output is the price ratio expanded by 1e18
return _getPriceFromSqrtX96(sqrtPriceX96);
}
//this result is expanded by UNISWAP_EXPANSION_FACTOR
function _getPriceFromSqrtX96(uint160 _sqrtPriceX96)
internal
pure
returns (uint256 price_)
{
uint256 priceX96 = (uint256(_sqrtPriceX96) * uint256(_sqrtPriceX96)) /
(2**96);
// sqrtPrice is in X96 format so we scale it down to get the price
// Also note that this price is a relative price between the two tokens in the pool
// It's not a USD price
price_ = priceX96;
}
After fetching the prices, the last function to finally compute the required collateral is _getCollateralTokensAmountEquivalentToPrincipalTokens :
// LenderCommitmentGroup_Smart.sol
function _getCollateralTokensAmountEquivalentToPrincipalTokens(
uint256 principalTokenAmountValue,
uint256 pairPriceWithTwap,
uint256 pairPriceImmediate,
bool principalTokenIsToken0
) internal pure returns (uint256 collateralTokensAmountToMatchValue) {
if (principalTokenIsToken0) {
//token 1 to token 0 ?
uint256 worstCasePairPrice = Math.min(
pairPriceWithTwap,
pairPriceImmediate
);
collateralTokensAmountToMatchValue = token1ToToken0(
principalTokenAmountValue,
worstCasePairPrice //if this is lower, collateral tokens amt will be higher
);
} else {
//token 0 to token 1 ?
uint256 worstCasePairPrice = Math.max(
pairPriceWithTwap,
pairPriceImmediate
);
collateralTokensAmountToMatchValue = token0ToToken1(
principalTokenAmountValue,
worstCasePairPrice //if this is lower, collateral tokens amt will be higher
);
}
}
function token0ToToken1(uint256 amountToken0, uint256 priceToken1PerToken0)
internal
pure
returns (uint256)
{
return
MathUpgradeable.mulDiv(
amountToken0,
UNISWAP_EXPANSION_FACTOR,
priceToken1PerToken0,
MathUpgradeable.Rounding.Up
);
}
//note: the price is still expanded by UNISWAP_EXPANSION_FACTOR
function token1ToToken0(uint256 amountToken1, uint256 priceToken1PerToken0)
internal
pure
returns (uint256)
{
return
MathUpgradeable.mulDiv(
amountToken1,
priceToken1PerToken0,
UNISWAP_EXPANSION_FACTOR,
MathUpgradeable.Rounding.Up
);
}
This function will convert the principalTokenAmountValue to the required collateral amount given the prices pairPriceWithTwap and pairPriceImmediate obtained from the pool. The token1ToToken0 and token0ToToken1 will perform the corresponding mulDiv operation so that the principal is converted properly.
As we can see, token1ToToken0 and token0ToToken1 perform the operation multiplying and dividing by UNISWAP_EXPANSION_FACTOR (which is 2**96). This is correct, and is done because as mentioned previously, the prices returned by the pool are in X96 notation.
The important concept to be aware of here is that after performing these computations, the amount of collateral computed will directly be given in the collateral units, because thetoken0ToToken1 and token1ToToken0 functions already scale and return the value in collateral units.
This leads us to the actual bug. As mentioned, when the getCollateralRequiredForPrincipalAmount function is called the amount returned will already be in the collateral units. However, when this function is called in acceptFundsForAcceptBid, the requiredCollateral returned by getCollateralRequiredForPrincipalAmountwill be incorrectly multiplied bySTANDARD_EXPANSION_FACTOR (1e18). This is because the code wrongly assumes that the amount required by getCollateralRequiredForPrincipalAmount is expanded by 10**18:
This makes it possible for users to pass a _collateralAmount smaller than requiredCollateral, given that the check to verify that the collateral passed by the user is actually enough will always scale the user’s supplied _collateralAmount value, making it possible for users to obtain undercollateralized loans.
Proof of Concept
The following proof of concept mimics the price that would be returned by the getCollateralRequiredForPrincipalAmount. In order to run it, create a foundry project that forks mainnet and paste the following code snippet:
function testVuln() public {
uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = uint32(3600); // from (before)
secondsAgos[1] = 0; // to (now)
(int56[] memory tickCumulatives, ) = IUniswapV3Pool(
0xC2e9F25Be6257c210d7Adf0D4Cd6E3E881ba25f8
).observe(secondsAgos); // DAI / ETH pool with 3600 seconds observation
uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick(
int24((tickCumulatives[1] - tickCumulatives[0]) / int32(3600))
);
uint256 priceX96 = FullMath.mulDiv(sqrtPriceX96, sqrtPriceX96, 2 ** 96);
console.log("Computed price in X96 notation: ", priceX96);
uint256 principalAmount = 1e18; // ETH is principal
console.log("Amount returned by getCollateralRequiredForPrincipalAmount: ", (principalAmount * 2 ** 96) / priceX96);
}
The computation performed in the final console log ((principalAmount * 2 ** 96) / priceX96)) corresponds to the computation that would be performed by the token0ToToken1 function. After running the code, we can see how the logged amount is in the correct collateral units (DAI units, 18 decimals).
Note: FullMath.mulDiv() is used to perform the multiplication executed in _getPriceFromSqrtX96 because the implementation of _getPriceFromSqrtX96 is flawed and overflows (related to another bug).
Impact
High. Scaling the user-supplied _collateralAmount by the STANDARD_EXPANSION_FACTOR when checking the required collateral will always make the _collateralAmount supplied be greater than the required collateral computed by the uniswap prices. This will allow users to obtain undercollateralized loans and drain the pool.
0xadrii
high
Multiplying the collateral amount by the
STANDARD_EXPANSION_FACTOR
when checking the required collateral is incorrect and allows borrowers to get undercollateralized loansSummary
It is possible to obtain undercollateralized loans due to the fact that LenderCommitmentGroup_Smart checks the required collateral wrongly.
Vulnerability Detail
The
LenderCommitmentGroup_Smart
contract acts as a lending pool inTellerV2
. Users can request loans and theLenderCommitmentGroup_Smart
will act as the lender of those loans. However, these loans are constrained and must be overcollateralized.This report describes a vulnerability related with scaling a price incorrectly and allowing borrowers to take undercollateralized loans. In order to understand it properly, we must first understand how
LenderCommitmentGroup_Smart
obtains the required collateral amount so that it can verify if the user is actually supplying the proper collateral amount.In order to compute the required collateral, the
LenderCommitmentGroup_Smart
will make use of a uniswap v3 pool from the collateral and principal tokens. The required amount of collateral will then be given by thegetCollateralRequiredForPrincipalAmount
function:This function internally calls the
_calculateCollateralTokensAmountEquivalentToPrincipalTokens
function, which will fetch and compute the price given by the uniswap pool:It is important to understand the format in which the prices
pairPriceWithTwap
andpairPriceImmediate
are returned. As we can see, prior to executing the_getCollateralTokensAmountEquivalentToPrincipalTokens
function, the pair price is fetched by querying_getUniswapV3TokenPairPrice
. This function will return the pair’s price in Uniswap’s X96 format (you can check more about this concept [here](https://blog.uniswap.org/uniswap-v3-math-primer)):After fetching the prices, the last function to finally compute the required collateral is
_getCollateralTokensAmountEquivalentToPrincipalTokens
:This function will convert the
principalTokenAmountValue
to the required collateral amount given the pricespairPriceWithTwap
andpairPriceImmediate
obtained from the pool. Thetoken1ToToken0
andtoken0ToToken1
will perform the correspondingmulDiv
operation so that the principal is converted properly.As we can see,
token1ToToken0
andtoken0ToToken1
perform the operation multiplying and dividing byUNISWAP_EXPANSION_FACTOR
(which is 2**96). This is correct, and is done because as mentioned previously, the prices returned by the pool are in X96 notation.The important concept to be aware of here is that after performing these computations, the amount of collateral computed will directly be given in the collateral units, because the
token0ToToken1
andtoken1ToToken0
functions already scale and return the value in collateral units.This leads us to the actual bug. As mentioned, when the
getCollateralRequiredForPrincipalAmount
function is called the amount returned will already be in the collateral units. However, when this function is called inacceptFundsForAcceptBid
, therequiredCollateral
returned bygetCollateralRequiredForPrincipalAmount
will be incorrectly multiplied bySTANDARD_EXPANSION_FACTOR
(1e18). This is because the code wrongly assumes that the amount required bygetCollateralRequiredForPrincipalAmount
is expanded by 10**18:This makes it possible for users to pass a
_collateralAmount
smaller thanrequiredCollateral
, given that the check to verify that the collateral passed by the user is actually enough will always scale the user’s supplied_collateralAmount
value, making it possible for users to obtain undercollateralized loans.Proof of Concept
The following proof of concept mimics the price that would be returned by the
getCollateralRequiredForPrincipalAmount
. In order to run it, create a foundry project that forks mainnet and paste the following code snippet:The computation performed in the final console log (
(principalAmount * 2 ** 96) / priceX96)
) corresponds to the computation that would be performed by thetoken0ToToken1
function. After running the code, we can see how the logged amount is in the correct collateral units (DAI units, 18 decimals).Note: FullMath.mulDiv() is used to perform the multiplication executed in
_getPriceFromSqrtX96
because the implementation of_getPriceFromSqrtX96
is flawed and overflows (related to another bug).Impact
High. Scaling the user-supplied _collateralAmount by the STANDARD_EXPANSION_FACTOR when checking the required collateral will always make the _collateralAmount supplied be greater than the required collateral computed by the uniswap prices. This will allow users to obtain undercollateralized loans and drain the pool.
Code Snippet
https://github.com/sherlock-audit/2024-04-teller-finance/blob/main/teller-protocol-v2-audit-2024/packages/contracts/contracts/LenderCommitmentForwarder/extensions/LenderCommitmentGroup/LenderCommitmentGroup_Smart.sol#L368
Tool used
Manual Review
Recommendation
Remove the price scaling when checking the required collateral:
Duplicate of #58