QuantAMM

QuantAMM
49,600 OP
View results
Submission Details
Severity: low
Invalid

Missing Ownership Verification in `UpliftOnlyExample` Contract

Summary

The UpliftOnlyExample contract relies exclusively on its internal bookkeeping (poolsFeeData) for ownership checks when users withdraw liquidity tokenized as NFTs. Because it does not validate actual NFT ownership via lpNFT.ownerOf(tokenID), an old owner is able to withdraw funds after transferring the NFT to a new owner.

Vulnerability Details

The vulnerability resides in the removeLiquidityProportional function. This function only checks if msg.sender has deposit records in poolsFeeData[pool][msg.sender] but does not confirm if msg.sender still owns the associated NFT at the ERC721 level (lpNFT.ownerOf(tokenId)).

If the afterUpdate hook, which synchronizes poolsFeeData with the actual NFT ownership, is not called during a transfer or fails, the internal data remains outdated. This allows the old owner of the NFT to withdraw liquidity even after transferring the NFT to a new owner.

The root cause lies in the contract's assumption that poolsFeeData is always accurate, which is not guaranteed in scenarios where afterUpdate is bypassed.

  • UpliftOnlyExample.sol - removeLiquidityProportional

  • Location: The removeLiquidityProportional function checks if there are deposits registered under the user in poolsFeeData[pool][msg.sender], but it does not confirm whether msg.sender is still the real owner of the NFT in the ERC721 contract.

  • Cause: If the NFT transfer (e.g., transferFrom) does not call afterUpdate or fails to do so, the records in poolsFeeData remain outdated. The "old owner" is still listed as the owner in poolsFeeData, even though the NFT is now held by another address in the lpNFT contract.

  • Effect: The contract does not include require(lpNFT.ownerOf(tokenID) == msg.sender), allowing the old owner, identified internally, to withdraw liquidity that no longer belongs to them.

Impact

The root cause centers on the contract’s failure to confirm real NFT ownership on withdrawal. This allows:

  • Unauthorized Withdrawals: The old NFT owner (who has since transferred the NFT) remains recognized in poolsFeeData and can still initiate a withdrawal.

  • High Financial Risk: If significant liquidity is tokenized, the new NFT owner loses funds, and the old owner gains them without rightful entitlement.

  • Desynchronization: Internal bookkeeping and actual ERC721 ownership become unsynchronized, producing confusion and potentially undermining trust in the system.

Proof of Concept

Below is a test named testVulnerability_MissingAfterUpdateAllowsOldOwnerWithdraw that illustrates the issue. It deposits liquidity for Bob, then force-changes the NFT’s ownership to Alice without calling afterUpdate. At this point, Bob is no longer the real owner at the ERC721 level, but the contract still considers him eligible to withdraw:

  • Add the following test to pkg/pool-hooks/test/foundry/UpliftExample.t.sol:

function testVulnerability_MissingAfterUpdateAllowsOldOwnerWithdraw() public {
// --- (1) BOB MAKES A DEPOSIT ---------------------------------------------
console2.log('[STEP 1] Bob deposits liquidity');
// Capture Bob's initial DAI/USDC balances
uint256 bobDaiStart = dai.balanceOf(bob);
uint256 bobUsdcStart = usdc.balanceOf(bob);
console2.log("Bob's DAI before deposit:", bobDaiStart);
console2.log("Bob's USDC before deposit:", bobUsdcStart);
// Bob deposits and mints an NFT representing his position
uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)]
.toMemoryArray();
vm.startPrank(bob);
upliftOnlyRouter.addLiquidityProportional(
pool,
maxAmountsIn,
bptAmount,
false,
bytes('')
);
vm.stopPrank();
// Check Bob's deposit
UpliftOnlyExample.FeeData[] memory bobFees = upliftOnlyRouter
.getUserPoolFeeData(pool, bob);
assertEq(bobFees.length, 1, 'Bob should have exactly 1 deposit');
console2.log("Bob's deposits after adding liquidity:", bobFees.length);
// The token ID of the newly minted NFT
uint256 mintedTokenId = bobFees[0].tokenID;
assertEq(mintedTokenId, 1, 'First minted token ID should be 1');
console2.log('Minted NFT tokenId for Bob:', mintedTokenId);
// --- (2) SIMULATE A TRANSFER WITHOUT 'afterUpdate' ------------------------
console2.log(
'[STEP 2] Simulate NFT transfer to Alice WITHOUT calling afterUpdate'
);
// Normally LPNFT.transferFrom calls afterUpdate, but let's skip it here
LPNFT lpNft = upliftOnlyRouter.lpNFT();
// Confirm Bob is official owner (ERC721) at this moment
assertEq(
lpNft.ownerOf(mintedTokenId),
bob,
'Bob should own tokenId=1 initially'
);
console2.log(
'Current official owner of tokenId:',
lpNft.ownerOf(mintedTokenId)
);
// Force-change storage to set Alice as the official ERC721 owner
bytes32 slot = keccak256(abi.encode(mintedTokenId, uint256(2)));
vm.store(address(lpNft), slot, bytes32(uint256(uint160(address(alice)))));
// Confirm Alice is now recognized in ERC721, but poolsFeeData is not updated
assertEq(
lpNft.ownerOf(mintedTokenId),
alice,
'Alice should appear as new owner after store hack'
);
console2.log(
'New official owner of tokenId after forced store:',
lpNft.ownerOf(mintedTokenId)
);
// --- (3) BOB WITHDRAWS AS IF HE STILL OWNS THE NFT -----------------------
console2.log(
'[STEP 3] Bob withdraws liquidity despite no longer being the real NFT owner'
);
// Capture Bob's balances before withdrawal
uint256 bobDaiBeforeWithdrawal = dai.balanceOf(bob);
uint256 bobUsdcBeforeWithdrawal = usdc.balanceOf(bob);
console2.log("Bob's DAI before withdrawal:", bobDaiBeforeWithdrawal);
console2.log("Bob's USDC before withdrawal:", bobUsdcBeforeWithdrawal);
// Contract only checks poolsFeeData, not lpNFT.ownerOf(tokenID)
uint256[] memory minAmountsOut = new uint256[]();
minAmountsOut[0] = 0;
minAmountsOut[1] = 0;
vm.startPrank(bob);
upliftOnlyRouter.removeLiquidityProportional(
bptAmount,
minAmountsOut,
false,
pool
);
vm.stopPrank();
console2.log('Bob successfully withdrew liquidity (vulnerable scenario).');
// Capture Bob's balances after withdrawal
uint256 bobDaiAfterWithdrawal = dai.balanceOf(bob);
uint256 bobUsdcAfterWithdrawal = usdc.balanceOf(bob);
console2.log("Bob's DAI after withdrawal:", bobDaiAfterWithdrawal);
console2.log("Bob's USDC after withdrawal:", bobUsdcAfterWithdrawal);
// Bob's position in poolsFeeData should now be cleared
bobFees = upliftOnlyRouter.getUserPoolFeeData(pool, bob);
assertEq(
bobFees.length,
0,
'Bob should have no remaining deposits after withdrawal'
);
console2.log("Bob's deposits after withdrawal:", bobFees.length);
// Meanwhile, Alice is the real ERC721 owner but has no record in poolsFeeData
UpliftOnlyExample.FeeData[] memory aliceFees = upliftOnlyRouter
.getUserPoolFeeData(pool, alice);
assertEq(
aliceFees.length,
0,
'Alice should have no deposits in poolsFeeData'
);
console2.log("Alice's deposits in poolsFeeData:", aliceFees.length);
// --- CONCLUSION ----------------------------------------------------------
// The NFT is formally in Alice's possession (lpNft.ownerOf(mintedTokenId) == alice),
// but the contract only used `poolsFeeData` for ownership checks, allowing Bob
// to withdraw even though he's not the real NFT owner anymore.
console2.log(
"[CONCLUSION] Old owner (Bob) withdrew because the contract never checked 'lpNFT.ownerOf'."
);
}

Explanation

  1. Step 1: Bob deposits liquidity and receives an NFT (tokenId: 1). The deposit is recorded in poolsFeeData[pool][bob].

  2. Step 2: The NFT is force-transferred to Alice by manipulating storage. Now, Alice owns the NFT in the lpNFT contract, but poolsFeeData is not updated.

  3. Step 3: Bob successfully withdraws liquidity because the contract does not verify real ownership via lpNFT.ownerOf.

Logs:
[STEP 1] Bob deposits liquidity
Bob's DAI before deposit: 1000000000000000000000000000
Bob's USDC before deposit: 1000000000000000000000000000
Bob's deposits after adding liquidity: 1
Minted NFT tokenId for Bob: 1
[STEP 2] Simulate NFT transfer to Alice WITHOUT calling afterUpdate
Current official owner of tokenId: 0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e
New official owner of tokenId after forced store: 0x328809Bc894f92807417D2dAD6b7C998c1aFdac6
[STEP 3] Bob withdraws liquidity despite no longer being the real NFT owner
Bob's DAI before withdrawal: 999999000000000000000000000
Bob's USDC before withdrawal: 999999000000000000000000000
Bob successfully withdrew liquidity (vulnerable scenario).
Bob's DAI after withdrawal: 999999999500000000000000000
Bob's USDC after withdrawal: 999999999500000000000000000
Bob's deposits after withdrawal: 0
Alice's deposits in poolsFeeData: 0
[CONCLUSION] Old owner (Bob) withdrew because the contract never checked 'lpNFT.ownerOf'.

Logs confirm that Bob's final withdraw call succeeds, even though Alice is the real NFT owner

Tools Used

  • Foundry: Used to develop and run tests validating that Bob, the old NFT holder, withdraws after ownership has changed.

  • Manual Code Review: Confirms that removeLiquidityProportional never checks lpNFT.ownerOf(tokenID).

Recommendations

  1. Check Real Ownership:
    Implement a direct check in removeLiquidityProportional similar to:

    require(lpNFT.ownerOf(tokenId) == msg.sender, "Not the real owner");

    This prevents an old owner from withdrawing.

  2. Enforce afterUpdate:
    In the LPNFT contract, automatically invoke afterUpdate in _beforeTokenTransfer or _afterTokenTransfer so poolsFeeData stays aligned with actual ownership.

  3. Remove Redundant Bookkeeping:
    Consider using lpNFT.ownerOf(tokenId) to drive all ownership checks instead of storing parallel records in poolsFeeData. This avoids potential desynchronization.

  4. Implement _verifyRealNftOwnership in removeLiquidityProportional
    Incorporate a dedicated function (e.g., _verifyRealNftOwnership) to confirm that the caller truly holds the NFT at the ERC721 level. For instance:

    // Private function that iterates each deposit record and verifies NFT ownership
    function _verifyRealNftOwnership(
    address pool,
    address user,
    uint256 bptAmountIn
    ) private view {
    FeeData[] memory userFeeData = poolsFeeData[pool][user];
    uint256 totalFound;
    for (uint256 i = 0; i < userFeeData.length; i++) {
    uint256 tokenId = userFeeData[i].tokenID;
    if (lpNFT.ownerOf(tokenId) != user) {
    revert WithdrawalByNonOwner(user, pool, bptAmountIn);
    }
    totalFound += userFeeData[i].amount;
    }
    // Prevent the user from withdrawing more than the total they truly own
    if (bptAmountIn > totalFound) {
    revert WithdrawalByNonOwner(user, pool, bptAmountIn);
    }
    }
    function removeLiquidityProportional(
    uint256 bptAmountIn,
    uint256[] memory minAmountsOut,
    bool wethIsEth,
    address pool
    ) external payable returns (uint256[] memory amountsOut) {
    _verifyRealNftOwnership(pool, msg.sender, bptAmountIn);
    ...
    }

    This code ensures the old owner cannot withdraw if the NFT is no longer in their possession.

Validation Through Testing

  • After adding the recommended mitigation function _verifyRealNftOwnership to the UpliftOnlyExample contract, include the following test in pkg/pool-hooks/test/foundry/UpliftExample.t.sol:

function testMitigation_RealNftOwnershipPreventsOldOwnerWithdraw() public {
// STEP 1 - Bob deposits liquidity
console2.log('[STEP 1] Bob deposits liquidity');
uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)]
.toMemoryArray();
vm.startPrank(bob);
upliftOnlyRouter.addLiquidityProportional(
pool,
maxAmountsIn,
bptAmount,
false,
bytes('')
);
vm.stopPrank();
// Check Bob's deposit
UpliftOnlyExample.FeeData[] memory bobFees = upliftOnlyRouter
.getUserPoolFeeData(pool, bob);
assertEq(bobFees.length, 1, 'Bob should have exactly 1 deposit');
uint256 mintedTokenId = bobFees[0].tokenID;
console2.log('Minted NFT tokenId:', mintedTokenId);
// Confirm nftPool mapping is correct
assertEq(
upliftOnlyRouter.nftPool(mintedTokenId),
pool,
'nftPool mapping mismatch'
);
// STEP 2 - Force NFT transfer to Alice without `afterUpdate`
console2.log('[STEP 2] Force NFT transfer to Alice (bypassing afterUpdate)');
// LPNFT's normal transferFrom would call afterUpdate, but let's skip it to simulate a bug/omission
LPNFT lpNft = upliftOnlyRouter.lpNFT();
assertEq(
lpNft.ownerOf(mintedTokenId),
bob,
'Bob should be current NFT owner'
);
// Calculate the storage slot for _owners[tokenId]
bytes32 slot = keccak256(abi.encode(mintedTokenId, uint256(2)));
// Use vm.store to forcibly set Alice as the owner
console2.log('Forcing NFT ownership at storage level to Alice...');
vm.store(address(lpNft), slot, bytes32(uint256(uint160(address(alice)))));
// Verify that ERC721 now reports Alice as the owner
assertEq(
lpNft.ownerOf(mintedTokenId),
alice,
'Alice should appear as new owner'
);
console2.log('ERC721 now shows Alice as the official owner');
// STEP 3 - Bob attempts withdrawal, should revert
console2.log(
'[STEP 3] Bob tries to withdraw but should revert (mitigation check)'
);
uint256[] memory minAmountsOut = new uint256[]();
minAmountsOut[0] = 0;
minAmountsOut[1] = 0;
vm.startPrank(bob);
// We expect 'WithdrawalByNonOwner' because Bob is no longer the real NFT owner
vm.expectRevert(
abi.encodeWithSelector(
UpliftOnlyExample.WithdrawalByNonOwner.selector,
bob,
pool,
bptAmount
)
);
upliftOnlyRouter.removeLiquidityProportional(
bptAmount,
minAmountsOut,
false,
pool
);
vm.stopPrank();
// Conclusion
console2.log(
'[CONCLUSION] Because of real NFT ownership checks, Bob cannot withdraw if he no longer owns the NFT.'
);
}

Test Result

Logs:
[STEP 1] Bob deposits liquidity
Minted NFT tokenId: 1
[STEP 2] Force NFT transfer to Alice (bypassing afterUpdate)
Forcing NFT ownership at storage level to Alice...
ERC721 now shows Alice as the official owner
[STEP 3] Bob tries to withdraw but should revert (mitigation check)
[CONCLUSION] Because of real NFT ownership checks, Bob cannot withdraw if he no longer owns the NFT.

Result: The logs show that after Bob deposits and receives an NFT (tokenId = 1), we forcibly reassign the NFT to Alice’s address without calling afterUpdate. When Bob tries to withdraw, the contract checks real NFT ownership and correctly prevents Bob from withdrawing, confirming the mitigation works as intended.

Updates

Lead Judging Commences

n0kto Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Informational or Gas / Admin is trusted / Pool creation is trusted / User mistake / Suppositions

Please read the CodeHawks documentation to know which submissions are valid. If you disagree, provide a coded PoC and explain the real likelyhood and the detailed impact on the mainnet without any supposition (if, it could, etc) to prove your point.

Support

FAQs

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

Give us feedback!