Closed sherlock-admin2 closed 1 year ago
1 comment(s) were left on this issue during the judging contest.
tsvetanovv commented:
Same root as slot0 vulnerability
This is not a duplicate of #109, that describes using slot0
to calculate SqrtPriceX96
, this can't be used as it is, I had failing tests, when I used SqrtPriceX96
as it is in the code, thats because SqrtPriceX96
is not meant to be used as it is. If you read the discussion in here there are some post-processing to be done after fetching SqrtPriceX96
. uint160 SqrtPriceX96 is supposed to be converted into uint256. Most protocols use an additional SqrtPriceX96ToUint
function.
Here is a blog on this calculation
Escalate
Escalating on behalf of @Abelaby
Escalate
Escalating on behalf of @Abelaby
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
I want to further make this clear by adding that this issue makes _getHoldTokenAmountIn
to return wrong values for amount0
and amount1
and will make the underlyingPositionManager.increaseLiquidity
fail everytime when _restoreLiquidity
calls increaseLiquidity
.
In the case of the attached PoC in issue above, if you add console.log statements in LiquidityManager.sol
using foundry like this;
function
_increaseLiquidity(
address saleToken,
address holdToken,
LoanInfo memory loan,
uint256 amount0,
uint256 amount1
) private {
// increase if not equal to zero to avoid rounding down the amount of restored liquidity.
if (amount0 > 0) ++amount0; //@audit any benefit if reenter?
if (amount1 > 0) ++amount1;
// Call the increaseLiquidity function of underlyingPositionManager contract
// with IncreaseLiquidityParams struct as argument
console.log("before increaseLiquidity");
console.log("loan.tokenId",loan.tokenId);
console.log("amount0",amount0);
console.log("amount1",amount1);
console.log("Sender",msg.sender);
console.log("address of pos manager",address(underlyingPositionManager));
console.log("saletoken balance",IERC20(saleToken).balanceOf(address(this)));
console.log("holdtoken balance",IERC20(holdToken).balanceOf(address(this)));
console.log("Address of this", address(this));
//IERC20(saleToken).approve(address(underlyingPositionManager), amount1);
//IERC20(holdToken).approve(address(underlyingPositionManager), amount1);
(uint128 restoredLiquidity, , ) = underlyingPositionManager.increaseLiquidity(
INonfungiblePositionManager.IncreaseLiquidityParams({
tokenId: loan.tokenId,
amount0Desired: amount0,
amount1Desired: amount1,
amount0Min: 0,
amount1Min: 0,
deadline: block.timestamp
})
);
The call fails because amuont0
is 0 in this case which makes the call to fail; make amount0 any non-zero value and you can see that the call does not revert.
I agree that it's not a duplicate, but I don't understand the root cause and the extent of the potential impact of this issue. Could you specify those?
Also, what is the requirement for the function to incorrectly revert?
I can see above that when amount0
is 0, the call seem to revert. Does it happen anytime else?
Should the call succeed for amount0 == 0
?
@Abelaby
Hey @Czar102
Whenever the amount0 is 0 the call will revert. It wont revert for non-zero values, starting from 1. The impact is that this makes liquidation impossible and loss of liquidation bonus for liquidator.
This is due to when increaseLiquidity
is called, Uniswap calls mint and then uniswapv3MintCalback
, I'm not sure why but some amount of amount0 token is used up during this.
I initially thought SqrtPriceX96
has to be post processed but that doesn't seem to be the case, a simple fix is that when ever amount0 is 0 make it a min value of 1 and and have the liquidator transfer it to the borrowingManager and it will work . After execution, the remaining amount0 is transferred back to the liquidator (Notice that the existing code already transfers the amount0 back to the liquidator, only need functionality to transfer the min amount0 to the borrowingManager ).
Hi @Abelaby, thanks for more info on the issue. I have some more questions, though.
The impact is that this makes liquidation impossible (...)
So a position cannot be liquidated at all?
I initially thought
SqrtPriceX96
has to be post processed but that doesn't seem to be the case (...)
So only the PoC part of the issue is correct and everything else should be disregarded?
What would be the fix?
@Czar102 Yes, the position can't be liquidated, and this was a standard position, I used the same parameters as an existing position from the UniswapPositionNFT tokenId = 7 to mint my position in PoC.
The fix is as I mentioned; when amount0 is 0 make it 1 (MIN_VALUE) and have the caller(liquidator) transfer it to the manager contract. This will fix the issue.
I don't understand why would it fail for a zero amount. Can you look into why is a minimum amount needed?
@Czar102 Got it.
So when we call increaseLiquidity
to positionManager it calls LiquidityManagement#addLiquidity and there is a call to LiquidityAmounts#getLiquidityForAmounts
function getLiquidityForAmounts(
uint160 sqrtRatioX96,
uint160 sqrtRatioAX96,
uint160 sqrtRatioBX96,
uint256 amount0,
uint256 amount1
) internal pure returns (uint128 liquidity) {
if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);
if (sqrtRatioX96 <= sqrtRatioAX96) {
liquidity = getLiquidityForAmount0(sqrtRatioAX96, sqrtRatioBX96, amount0);
} else if (sqrtRatioX96 < sqrtRatioBX96) {
uint128 liquidity0 = getLiquidityForAmount0(sqrtRatioX96, sqrtRatioBX96, amount0);
uint128 liquidity1 = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioX96, amount1);
liquidity = liquidity0 < liquidity1 ? liquidity0 : liquidity1;
} else {
liquidity = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioBX96, amount1);
}
}
which returns the liquidity as 0 (This is caused by amount0 being 0).
Then the transaction calls pool.mint
with liquidity as 0, this can seen in the transaction trace in terminal
0xCBCdF9626bC03E24f779434178A73a0B4bad62eD::mint(0xC36442b4a4522E871399CD717aBDD847Ab11FE88, 253320, 264600, 0, 0x0000000000000000000000002260fac5e5542a773aa44fbcfedf7c193bc2c599000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000bb80000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b)
│ │ │ └─ ← "EvmError: Revert"
The pool.mint is this -> UniswapV3Pool#mint and it has a check
require(amount > 0);
Where amount = liquidity. This causes the call to fail. Hope its clear now.
I don't think there should be transactions with zero liquidity being done here, though. You can't increase liquidity by 0, that would make sense imo.
@Czar102 Yes that's exactly the case,
TLDR of my above explanation;
when amount0
is zero, it ultimately makes the call to increaseLiquidity
by 0, which makes it to fail.
In this function trace:
0xCBCdF9626bC03E24f779434178A73a0B4bad62eD::mint(0xC36442b4a4522E871399CD717aBDD847Ab11FE88, 253320, 264600, 0, 0x0000000000000000000000002260fac5e5542a773aa44fbcfedf7c193bc2c599000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000000000000000bb80000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b) │ │ │ └─ ← "EvmError: Revert"
Notice in above that mint(address recipient, int24 tickLower, int24 tickUpper, uint128 amount, bytes calldata data)
is called with the 4th argument (amount / liqudity ) as 0. ie; minting with zero liqudity.
Then I would be accepting the escalation and making this submission invalid. Don't see a bug or exploit there.
@Czar102 The bug is, when calling repay
the amount0
will be zero (This is not controlled by the caller in any way) and the function will revert always in this case.
So liquidations are impossible, that is a valid bug.
@cvetanovv @fann95 could you weigh in?
isEmergency: false,
internalSwapPoolfee: 0, //token1 - WETH
externalSwap: ApproveSwapAndPay.SwapParams({
swapTarget: address(0),
swapAmountInDataIndex: 0,
maxGasForCall: 0,
swapData: swapData
}),
borrowingKey: _borrowingKey,
swapSlippageBP1000: 0
});
I think the problems are in the repay parameters. internalSwapPoolfee: 0 and swapTarget: address(0). Try setting the variable internalSwapPoolfee to 3000. since you are trying to make an exchange in a 0th pool and adjust the price in the 3000th. There was actually a bug in the code that prevented pools with different fees from being used, but we fixed it as part of the fix #109 although no one reported about it.
@fann95 thanks for your comment, when I looked through the flow of repay() function, the function reverts before ever using internalSwapPoolfee
, params.fee is used in liquidityManager._restoreLiquidity() when if (holdTokenAmountIn > 0)
is true , which is false in this case.
And I also tried setting internalSwapPoolfee to 3000 which still reverts.
And I also tried setting internalSwapPoolfee to 3000 which still reverts.
Ok, I'll try to take a closer look at this and let you know.
@Czar102 @fann95
Found a similar issue in Uniswap v3-periphery repo But no comments so far.
Here is the output when implementing the Fix I recommended.
The fix is as I mentioned; when amount0 is 0 make it 1 (MIN_VALUE) and have the caller(liquidator) transfer it to the manager contract. This will fix the issue.
For simplicity, I had already transferred the MIN_VALUE to manager .
The problem is the small amount of liquidity that the borrower chose It's hard to imagine that someone will spend more on gas than they borrow, but I think that the calculation of the minimum liquidity and verification must be present. Although this is not critical. Fixed: https://github.com/RealWagmi/wagmi-leverage/commit/b5ed9dd9f433722588db8f357801f2664933de81
@Czar102 @cvetanovv Curious if you have opinions on whether this should be a valid low or medium? It feels like the only way this could happen is if the amount of liquidity chosen is extremely, extremely low (easily less than 1/10 of a cent, probably less than 1/100 of a cent). The low amount of liquidity chosen leads to the output amount being 0.
The impact was liquidation being impossible, but in this case, liquidation will never be profitable past gas costs anyways (no one is going to liquidate a 1/100 cent position profitably). As a result, I don't think this issue has any impact. Furthermore, I don't think anyone will attempt to create positions of sub 1/100 of a cent (again gas costs exist).
I would lean towards categorizing this as low for the reasons stated above. It doesn't feel like the requirements for a medium severity issue are met. That being said, the Watson did find an issue that was fixed by the sponsor, so I would not be opposed to giving Medium severity here. Up to your discretion.
The minimum size will depend on the tick range. The size of liquidity should be such that with any case extracted of liquidity (even when the current tick is close to the borders) we receive an amount of each token sufficient to complete a swap with a non-zero output. I have added a function for calculating the minimum liquidity, you can play with it.
@fann95 @detectiveking123
I want to point out a few things.
MINIMUM_BORROWED_AMOUNT
that the borrower is expected to clear, so this Liquidity is within the expected range,5750
and for liquidity = 5800
, the borrow() reverts with TOO_BIG_COLLATERAL
.As far as I understand: it's not possible to liquidate a position because there is insufficient liquidity in the uni v3 pool? This sounds like basic margin/leverage specification – you can't close a position if you can't swap the assets back.
In that case there is always an external swap option, right?
The problem is the small amount of liquidity that the borrower chose
So it's when the borrower's liquidity is "worth" zero tokens of both types? Or one type?
@fann95 Yes, it is indeed dependent on the tick. However, consider that the pool should be roughly balanced in terms of price (if you put in $X, you should get approximately $X out, otherwise it would have been arbed.. these are live V3 LP positions). If you're putting in some amount and getting 0 out, it feels like there's very little liquidity (in terms of USD) in the pool.
@Czar102 To answer your questions, theoretically only one type, but if there's a nontrivial amount of money in the pool, it would have been arbed. So in reality both types.
Correct me if I'm wrong here (@Abelaby), but rereading the code, I believe the liquidation functionality is technically still possible through repay
or takeOverDebt
, just use the externalSwap as was mentioned. Then you can avoid amount0
or amount1
ever being 0.
"As far as I understand: it's not possible to liquidate a position because there is insufficient liquidity in the uni v3 pool? This sounds like basic margin/leverage specification – you can't close a position if you can't swap the assets back." -- yes I think this is correct, the built-in swap will give you terrible outputs, so you should just use your own swap in this case.
It also feels like cases like this are the very reason externalSwap exists.
As far as I understand: it's not possible to liquidate a position because there is insufficient liquidity in the uni v3 pool? This sounds like basic margin/leverage specification – you can't close a position if you can't swap the assets back.
In that case there is always an external swap option, right?
The problem is the small amount of liquidity that the borrower chose
So it's when the borrower's liquidity is "worth" zero tokens of both types? Or one type?
The problem is also in the NonfungiblePositionManager in which you need to transfer the amounts of tokens and not the amount of liquidity, as directly in the Uniswap pool.
@fann95 @detectiveking123
I want to point out a few things.
- The liquidity that the borrower chose already passes through a check, ie; there is a
MINIMUM_BORROWED_AMOUNT
that the borrower is expected to clear, so this Liquidity is within the expected range,- Also for a sanity check, I tried adjusting the liquidity in the test, I was getting the mint() error up to liquidity of
5750
and forliquidity = 5800
, the borrow() reverts withTOO_BIG_COLLATERAL
.
increase the minimum value in BorrowParams to fix this
@fann95 Can you explain more what's going wrong specifically with the periphery NonfungiblePositionManager
? I read the code and it looks like UniswapV3Pool
requires the liquidity
parameter in mint
to be positive, but other than that (which I think is a non-issue) I don't see anything that would cause a revert.
My current understanding is that:
(amount0, amount1) = LiquidityAmounts.getAmountsForLiquidity(
cache.sqrtPriceX96,
TickMath.getSqrtRatioAtTick(cache.tickLower),
TickMath.getSqrtRatioAtTick(cache.tickUpper),
loan.liquidity
);
Can lead to amount0 = 0
, for example. Which makes sense. But why does this lead to an issue with _increaseLiquidity
?
My best guess is that perhaps these should be incremented even if amount0 = 0
or amount1 = 0
?
if (amount0 > 0) ++amount0;
if (amount1 > 0) ++amount1;
(Perhaps the issue is that we are just rounding down on the returned liquidity)
Can you also comment on the impact here? My impression is that liquidations can still run fine through externalSwap.
@detectiveking123 thank you. I agree, it seems to be a rounding issue. Joining on the questions above. @fann95 @Abelaby
@Czar102 @detectiveking123
amount0 is used as a parameter when calling increaseLiquidity
(uint128 restoredLiquidity, , ) = underlyingPositionManager.increaseLiquidity(
INonfungiblePositionManager.IncreaseLiquidityParams({
tokenId: loan.tokenId,
amount0Desired: amount0, ///////////////////////// Currently equal to 0
amount1Desired: amount1 ,
amount0Min: 0,
amount1Min: 0,
deadline: block.timestamp
})
Which as I mentioned before in the call traces here causes the issue (To cause mint() to fail).
My best guess is that perhaps these should be incremented even if amount0 = 0 or amount1 = 0?
This was the fix that I recommended earlier. The current code:
if (amount0 > 0) ++amount0;
if (amount1 > 0) ++amount1;
It wont increment if amount0 = 0 or amount1 = 0.
And with sufficient console.log() messages, we can see that this call wont even go to the externalSwap sections, which is inside this code block.
increase the minimum value in BorrowParams to fix this
@fann95 I did this but the issue still persists (mint() issue).
If the current tick is within the range of the position, then neither amount 0 nor amount 1 can be equal to zero and must be rounded up after calculation.I made the changes and now everything works fine.At the time of liquidity extraction, they are checked for a minimum amount. So, it is impossible to take out a loan with liquidity that cannot be restored later.
It seems the watson didn't manage to describe the issue properly. Even though the PoC presents a bug, the core issue wasn't described, which is what defines a valid submission. Planning to accept the escalation (not a duplicate), but this submission needs to be considered invalid. Otherwise, this would constitute a precedent where an invalid description is considered valid based on the fact that a valid finding was found based on the submission. It is watson's responsibility to fully understand the issue and describe it before the contest ends.
Thank you @Abelaby for escalating this issue and exploring the issue further. I'm sorry it won't yield any payout.
@Czar102 I understand your reasoning, but I was able to provide value to the sponsors and had to create a custom PoC in foundry for this one. And I had to follow up through this issue for weeks now which took considerable time and affected my allocated time for other ongoing contests.I believe this disregards the efforts put forth by Watsons o provide value to the sponsors, I was able to provide value and that should be regarded.
And @fann95 I can't see the fix, the repo is private I believe.
I was able to provide value to the sponsors and had to create a custom PoC in foundry for this one
I definitely agree. But diluting the contest pot would be unfair with respect to other watsons and would break the rules set before the competition. I'm sorry it ends that way, but I don't see another solution. I'm sure the Sherlock team will find a solution to this issue in close future, but for now there seems to be nothing I can do.
@Czar102 I understand your reasoning, but I was able to provide value to the sponsors and had to create a custom PoC in foundry for this one. And I had to follow up through this issue for weeks now which took considerable time and affected my allocated time for other ongoing contests.I believe this disregards the efforts put forth by Watsons o provide value to the sponsors, I was able to provide value and that should be regarded.
And @fann95 I can't see the fix, the repo is private I believe.
Yes, unfortunately the repository is private for now. Thank you for your time on this issue.
@Czar102 @fann95
In case of non-obvious issues with complex vulnerabilities/attack paths, Watson must submit a valid POC for the issue to be considered valid and rewarded.
Can't this be considered under this category, the issue was non-obvious and was 'only' able to catch because of the PoC that I submitted.
@cvetanovv @Evert0x tagging you guys for visibility.
This decision is on me, so no need to explicitly tag the sponsor or other people. I asked Evert to double check my decision here anyway.
In case of non-obvious issues with complex vulnerabilities/attack paths, Watson must submit a valid POC for the issue to be considered valid and rewarded.
Can't this be considered under this category, the issue was non-obvious and was 'only' able to catch because of the PoC that I submitted.
This is an additional requirement, meaning that the watson needs to additionally submit a PoC. It cannot be considered a substitute of a bug report.
@Czar102 I understand, tagged them because Sherlock rules states that judge has the final word.
I believe it's unfair to the Watson , the current reasoning was there from the start and if this was to be the final outcome I feel like I was knowingly 'exploited' into providing value to the project over these few weeks.
Yes, it seems like this is the issue:
if (amount0 > 0) ++amount0;
if (amount1 > 0) ++amount1;
Just make this ++amount0
and ++amount1
instead, without the if statements.
@Abelaby can you explain why the code wouldn't get to the externalSwap section? I agree that is the correct code block. But _increaseLiquidity
is only called afterwards.
@detectiveking123 there is a condition to be met, you can figure it out by using console log statements. And I think the sponsors already implemented a fix, the above one is not necessary and can't be used implicitly as you mentioned.
Result: Invalid Unique
The submitter just provided a clue in their submission, after a deep investigation by different parties the actual bug was discovered. The original report is of insufficient quality to be rewarded as a bug report.
@Evert0x Agree with the decision, but the escalation by midori-fuse should be accepted
I believe, because this is not a duplicate.
It's fine, glad I could help securing the protocol. I'd say this is well worth it.
Besides I have like 20 escalations left anyway ¯\_(ツ)_/¯ If anything then you just owe me one ice cream.
Fix looks good. amount0 and amount1 are no longer only incremented when they are zero. Instead calculations now always round up in favor of the LP.
Kral01
high
[H-02] SqrtPriceX96 is calculation is not done correctly which can lead to loss of funds.
Summary
SqrtPriceX96
is calculation is done wrongly which can lead to loss of funds makingrepay
function unusable in non-emergency cases.Vulnerability Detail
While calling repay function in LiquidityBorrowingManager.sol during non-emergency cases the function calls LiquidityManager#_restoreLiquidity which calls LiquidityManager#_getCurrentSqrtPriceX96 which gives the value of SqrtPriceX96 , but this value can't be used as it is. Our protocol does this and this makes the _increaseLiquidity return totally wrong values for
amount1
and 'amount0'. This makes the contract to make wrong calculations later on or totally revert therepay
function most of the times.Impact
Here is an end-end coded PoC that shows how the wrong value of
SqrtPriceX96
that ultimately reverts therepay
function everytime it is called in non-emergency conditions.test_func: @forge test --fork-url ${MAINNET_RPC} --fork-block-number ${MAINNET_BLOCK} -vvvv --ffi --mt ${P}
The test passes, we can see from the error logs in foundry:
The function reverts when calling increaseLiquidity ->mint. With sufficient console.log() messages we can find that due to wrong value of
SqrtPriceX96
the calculatedamount0
when calling increaseLiquidity is equal to 0 which is causing the issue and making the function revert.This can make liquidation impossible and loss of liquidation bonus for liquidator.
Code Snippet
https://github.com/sherlock-audit/2023-10-real-wagmi/blob/b33752757fd6a9f404b8577c1eae6c5774b3a0db/wagmi-leverage/contracts/abstract/LiquidityManager.sol#L331C3-L343C1
Calculation for
SqrtPriceX96
is done wrong.Tool used
Manual Review
Recommendation
There are multiple ways to correctly calculate SqrtPriceX96: Please refer to this discussion in ethereum stackexchange - > link