Closed sherlock-admin2 closed 8 months ago
Request PoC
Seems Invalid, burning NFT poses a threat only to its owner since he will lose all collateral. Would be good to have a PoC to show potential loss involved.
PoC requested from @0xDetermination
Requests remaining: 2
Hi @nevillehuang, providing PoC below:
First, run the test "emergency repay will be successful for PosManNFT owner if the collateral is depleted" like normal and console.log the balance of the last liquidator after emergency liquidating. That's the last loan for that borrowing position, so the last liquidator will get the liquidationBonus
. See below:
it("emergency repay will be successful for PosManNFT owner if the collateral is depleted", async () => {
....
/////////////LOG BALANCE///////////////
console.log('last emergency liquidator token balance before', await WETH.balanceOf(owner.address));
await time.increase(100);
deadline = (await time.latest()) + 60;
await expect(borrowingManager.connect(owner).repay(params, deadline))
.to.emit(borrowingManager, "EmergencyLoanClosure")
.withArgs(bob.address, owner.address, borrowingKey);
...
/////////////LOG BALANCE///////////////
console.log('last emergency liquidator token balance after', await WETH.balanceOf(owner.address));
Then to see the difference if a token is burned, run the modified test below (changes are burning the first liquidator's token and commenting out some of the position checks since the burned token will make those fail).
it("emergency repay will be successful for PosManNFT owner if the collateral is depleted", async () => {
let debt: ILiquidityBorrowingManager.BorrowingInfoExtStructOutput[] =
await borrowingManager.getBorrowerDebtsInfo(bob.address);
await time.increase(debt[1].estimatedLifeTime.toNumber() + 1);
let borrowingKey = (await borrowingManager.getBorrowingKeysForBorrower(bob.address))[1];
let deadline = (await time.latest()) + 60;
let params: ILiquidityBorrowingManager.RepayParamsStruct = {
returnOnlyHoldToken: true,
isEmergency: true, //emergency
internalSwapPoolfee: 0,
externalSwap: [],
borrowingKey: borrowingKey,
minHoldTokenOut: BigNumber.from(0),
minSaleTokenOut: BigNumber.from(0)
};
//console.log(debt);
let loans: ILiquidityManager.LoanInfoStructOutput[] = await borrowingManager.getLoansInfo(borrowingKey);
expect(loans.length).to.equal(3);
//console.log(loans);
/////////BURN TOKEN////////////
await nonfungiblePositionManager.connect(alice).burn(nftpos[0].tokenId);
/*await expect(borrowingManager.connect(alice).repay(params, deadline))
.to.emit(borrowingManager, "EmergencyLoanClosure")
.withArgs(bob.address, alice.address, borrowingKey);
expect(await borrowingManager.getLenderCreditsCount(nftpos[0].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[1].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[2].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[3].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[4].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[5].tokenId)).to.be.gt(0);
expect(await borrowingManager.getBorrowerDebtsCount(bob.address)).to.be.equal(2);*/
debt = await borrowingManager.getBorrowerDebtsInfo(bob.address);
//console.log(debt);
loans = await borrowingManager.getLoansInfo(borrowingKey);
//expect(loans.length).to.equal(2);
await time.increase(100);
deadline = (await time.latest()) + 60;
await expect(borrowingManager.connect(bob).repay(params, deadline))
.to.emit(borrowingManager, "EmergencyLoanClosure")
.withArgs(bob.address, bob.address, borrowingKey);
/*expect(await borrowingManager.getLenderCreditsCount(nftpos[0].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[1].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[2].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[3].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[4].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[5].tokenId)).to.be.gt(0);
expect(await borrowingManager.getBorrowerDebtsCount(bob.address)).to.be.equal(2);
debt = await borrowingManager.getBorrowerDebtsInfo(bob.address); */
//console.log(debt);
loans = await borrowingManager.getLoansInfo(borrowingKey);
//expect(loans.length).to.equal(1);
/////////////LOG BALANCE///////////////
console.log('last emergency liquidator token balance before', await WETH.balanceOf(owner.address));
await time.increase(100);
deadline = (await time.latest()) + 60;
await expect(borrowingManager.connect(owner).repay(params, deadline))
.to.emit(borrowingManager, "EmergencyLoanClosure")
.withArgs(bob.address, owner.address, borrowingKey);
/*expect(await borrowingManager.getLenderCreditsCount(nftpos[0].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[1].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[2].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[3].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[4].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[5].tokenId)).to.be.gt(0);
expect(await borrowingManager.getBorrowerDebtsCount(bob.address)).to.be.equal(1);*/
/////////////LOG BALANCE///////////////
console.log('last emergency liquidator token balance after', await WETH.balanceOf(owner.address));
});
In both tests the balance of the last liquidator (owner
) before liquidating is identical (98166360688065201240), but the balance after is smaller in the second test (100085253956926185648 vs. 100005725406022127556). The difference is from the liquidationBonus
not being transferred since alice
burned the token.
I also want to add that this issue is distinct from an issue like "if a lender burns the token their fees will be stuck"- in that case the lender will indeed only be self-griefing.
A malicious borrower could also use this bug to grief lenders. For example:
liquidationBonus
will not be disbursed to anyone.Since the max liquidation bonus is 1%, there could be a significant amount of loss (like if the borrower's position is $100,000 which is very likely due to the protocol enabling high leverage, then $1000 will be stuck in the contract and lenders won't receive that amount).
@fann95 Any thoughts on the above? The only way this seems to be economically viable is if the borrower's collateral has a lower value than a 1% liquidation bonus, which seems abit out of reach given the required significant price drop
@nevillehuang @fann95 This typically won't happen as it's not a direct griefing attack, the scenario is more like "if a borrower's position devalued over time and normal liquidation is not possible because the borrower won't repay the loan, then the borrower can lock/grief the liquidation bonus as he will never receive it anyways".
Another scenario that could happen (while unlikely) is that a borrower has a loan from some lender such that the value is <1% of the overall position, in which case the lender could grief with a damage/cost ratio >1 if normal liquidation is impossible.
it("emergency repay will be successful for PosManNFT owner if the collateral is depleted", async () => {
let debt: ILiquidityBorrowingManager.BorrowingInfoExtStructOutput[] =
await borrowingManager.getBorrowerDebtsInfo(bob.address);
await time.increase(debt[1].estimatedLifeTime.toNumber() + 1);
let borrowingKey = (await borrowingManager.getBorrowingKeysForBorrower(bob.address))[1];
let deadline = (await time.latest()) + 60;
let params: ILiquidityBorrowingManager.RepayParamsStruct = {
returnOnlyHoldToken: true,
isEmergency: true, //emergency
internalSwapPoolfee: 0,
externalSwap: [],
borrowingKey: borrowingKey,
minHoldTokenOut: BigNumber.from(0),
minSaleTokenOut: BigNumber.from(0)
};
//console.log(debt);
let loans: ILiquidityManager.LoanInfoStructOutput[] = await borrowingManager.getLoansInfo(borrowingKey);
expect(loans.length).to.equal(3);
//console.log(loans);
/////////BURN TOKEN////////////
await nonfungiblePositionManager.connect(alice).burn(nftpos[0].tokenId);
/*await expect(borrowingManager.connect(alice).repay(params, deadline))
.to.emit(borrowingManager, "EmergencyLoanClosure")
.withArgs(bob.address, alice.address, borrowingKey);
expect(await borrowingManager.getLenderCreditsCount(nftpos[0].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[1].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[2].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[3].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[4].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[5].tokenId)).to.be.gt(0);
expect(await borrowingManager.getBorrowerDebtsCount(bob.address)).to.be.equal(2);*/
debt = await borrowingManager.getBorrowerDebtsInfo(bob.address);
//console.log(debt);
loans = await borrowingManager.getLoansInfo(borrowingKey);
//expect(loans.length).to.equal(2);
await time.increase(100);
deadline = (await time.latest()) + 60;
await expect(borrowingManager.connect(bob).repay(params, deadline))
.to.emit(borrowingManager, "EmergencyLoanClosure")
.withArgs(bob.address, bob.address, borrowingKey);
/*expect(await borrowingManager.getLenderCreditsCount(nftpos[0].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[1].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[2].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[3].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[4].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[5].tokenId)).to.be.gt(0);
expect(await borrowingManager.getBorrowerDebtsCount(bob.address)).to.be.equal(2);
debt = await borrowingManager.getBorrowerDebtsInfo(bob.address); */
//console.log(debt);
loans = await borrowingManager.getLoansInfo(borrowingKey);
//expect(loans.length).to.equal(1);
/////////////LOG BALANCE///////////////
console.log('last emergency liquidator token balance before', await WETH.balanceOf(owner.address));
await time.increase(100);
deadline = (await time.latest()) + 60;
await expect(borrowingManager.connect(owner).repay(params, deadline))
.to.emit(borrowingManager, "EmergencyLoanClosure")
.withArgs(bob.address, owner.address, borrowingKey);
/*expect(await borrowingManager.getLenderCreditsCount(nftpos[0].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[1].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[2].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[3].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[4].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[5].tokenId)).to.be.gt(0);
expect(await borrowingManager.getBorrowerDebtsCount(bob.address)).to.be.equal(1);*/
/////////////LOG BALANCE///////////////
console.log('last emergency liquidator token balance after', await WETH.balanceOf(owner.address));
params.isEmergency = false;
await expect(borrowingManager.connect(owner).repay(params, deadline))
.to.emit(borrowingManager, "Repay")
.withArgs(bob.address, owner.address, borrowingKey);
console.log('last liquidator token balance after repay(isEmergency = false)', await WETH.balanceOf(owner.address));
});
last emergency liquidator token balance before BigNumber { value: "98166360688065201240" }
last emergency liquidator token balance after BigNumber { value: "100005725406022127556" }
last liquidator token balance after repay(isEmergency = false) BigNumber { value: "101090782980961904620" }
but the liquidator was not the last)) I added 2 lines at the end of the test for understanding.. even if it is not possible to restore liquidity (low liquidity in the pool or another reason) since there are no longer any living NFTs, the position will still be closed and the one who closes the position will receive a liquidation bonus.
@nevillehuang @fann95 Thanks very much for the correction, I think this is indeed Low/Invalid- I missed the fact that the last liquidator can call repay as emergency, then call it again as non-emergency to collect the liquidation fee and the tokens from the burned loan.
@0xDetermination Got it thanks for the further insights, in that case closing this issue.
0xDetermination
medium
liquidationBonus
may be forever unclaimable if a lender burns their NFTSummary
In the case that normal liquidation is impossible and emergency liquidation must be used, a lender burning the UniV3 NFT can lock the
liquidationBonus
forever.Vulnerability Detail
Notice that the
liquidationBonus
is only transferred to the last emergency liquidator, i.e. whencompleteRepayment
equalstrue
:And this variable is only set to true when all loans are removed from the borrower's
loansInfo
in_calculateEmergencyLoanClosure()
, but this will never happen if a lender has burned the UniV3 NFT due to themsg.sender
check against the NFT owner:Therefore, if a borrower's position is only emergency-liquidatable then the
liquidationBonus
can be lost forever.Impact
Lenders won't receive the
liquidationBonus
and it may be forever unclaimable.Code Snippet
https://github.com/sherlock-audit/2024-02-leverage-contracts/blob/main/wagmi-leverage/contracts/LiquidityBorrowingManager.sol#L656-L659 https://github.com/sherlock-audit/2024-02-leverage-contracts/blob/main/wagmi-leverage/contracts/LiquidityBorrowingManager.sol#L784-L823
Tool used
Manual Review
Recommendation
Perform a check to see if the only loans left are from burned NFTs and set
completeRepayment
totrue
if so.Also want to mention that there's a similar issue that can be fixed in a similar way