0xde903046b5cdf27a5391b771f41e645e9cc670b649f7b87b1524fc4076f459830x5d93f216f17c225a8b5ffa34e74b7133436281eeBase0xc1d49fa32d150b31c4a5bf1cbf23cf7ac99eaf7dBaseOn Base block 29437439, an unprivileged attacker used Impermax V3's public bad-debt maintenance path to force a debt haircut on an LP-backed borrow, repay only the reduced remainder, and then redeem the full collateral NFT. The exploit sequence borrowed 201.595425653150513986 WETH, triggered a bad-debt restructure that wrote off 141.505102074743477723 WETH, repaid only 60.090323578407036263 WETH, recovered the collateral, and extracted 34.594959904382743033 ETH net profit after gas.
The root cause is a lender-loss invariant break in Impermax V3's restructure flow. restructureBadDebt() reduces debt through the borrowables but does not seize, burn, escrow, or otherwise remove borrower control over the collateral NFT. Because redeem() later checks only that debt is zero at redemption time, the borrower can keep the upside of the same collateral after the protocol has already socialized part of the loss.
Impermax V3 tracks debt by collateral NFT tokenId. In this market, the collateral contract wraps a TokenizedUniswapV3Position NFT, and the same tokenId can back both WETH and USDC borrowables that share the collateral contract. That matters because the borrower's effective safety margin depends on the live composition of the underlying Uniswap V3 LP position rather than on a static collateral value.
The collateralized LP position is path-dependent. A borrower can use ordinary Uniswap V3 swaps and reinvest() calls to alter the position's token mix and fee state, which in turn affects Impermax's underwater and liquidation calculations. Impermax then exposes as a public function on the collateral contract, so any actor who can make a position underwater can also trigger the restructure path without any privileged role.
restructureBadDebt(uint256 tokenId)The relevant public components are:
0x5d93f216f17c225a8b5ffa34e74b7133436281ee0xc1d49fa32d150b31c4a5bf1cbf23cf7ac99eaf7d0xa68f6075ae62ebd514d1600cb5035fa0e2210ef80x1c450d7d1fd98a0b04e30decfc83497b33a4f608This is an ATTACK-class protocol flaw rather than a benign MEV unwind. Impermax V3's restructure path forgives debt but does not transfer collateral control to lenders or to the protocol. That violates the core invariant that any debt write-down caused by an underwater position must be matched by lender-controlled collateral recovery.
The code-level mechanism is straightforward. In ImpermaxV3Collateral.restructureBadDebt(), the protocol computes postLiquidationCollateralRatio, requires it to be below 1e18, and then calls restructureDebt() on both borrowables. In ImpermaxV3Borrowable.restructureDebt(), the borrowable computes a repayAmount that represents the debt haircut and applies it through _updateBorrow(tokenId, 0, repayAmount), immediately reducing borrower principal and totalBorrows.
The collateral contract does not seize or lock the NFT in that path. It only stores blockOfLastRestructureOrLiquidation[tokenId] and emits RestructureBadDebt. Later, ImpermaxV3Collateral.redeem() allows the borrower to burn the collateral wrapper and withdraw the underlying NFT so long as both borrow balances are zero at redemption time. The exploit is therefore to make the position underwater, trigger a write-down, clear only the reduced residual debt, and then redeem the same collateral that should have been consumed by the loss event.
The critical victim-side code is the restructure path in the collateral contract:
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);
}
This function proves the first half of the flaw: once a position is underwater, Impermax calls into the borrowables to write down debt, but this branch itself does not confiscate collateral. The corresponding borrowable code shows the write-down is immediate accounting state, not a lender recovery mechanism:
function restructureDebt(uint tokenId, uint reduceToRatio) public nonReentrant update accrue {
require(msg.sender == collateral, "ImpermaxV3Borrowable: UNAUTHORIZED");
require(reduceToRatio < 1e18, "ImpermaxV3Borrowable: NOT_UNDERWATER");
uint _borrowBalance = borrowBalance(tokenId);
if (_borrowBalance == 0) return;
uint repayAmount = _borrowBalance.sub(_borrowBalance.mul(reduceToRatio).div(1e18));
(uint accountBorrowsPrior, uint accountBorrows, uint _totalBorrows) = _updateBorrow(tokenId, 0, repayAmount);
emit RestructureDebt(tokenId, reduceToRatio, repayAmount, accountBorrowsPrior, accountBorrows, _totalBorrows);
}
The second half of the flaw appears in redemption:
if (percentage == 1e18) {
redeemTokenId = tokenId;
_burn(tokenId);
INFTLP(underlying).safeTransferFrom(address(this), to, redeemTokenId);
if (data.length > 0) IImpermaxCallee(to).impermaxV3Redeem(msg.sender, tokenId, redeemTokenId, data);
require(IBorrowable(borrowable0).borrowBalance(tokenId) == 0, "ImpermaxV3Collateral: INSUFFICIENT_LIQUIDITY");
require(IBorrowable(borrowable1).borrowBalance(tokenId) == 0, "ImpermaxV3Collateral: INSUFFICIENT_LIQUIDITY");
}
The borrower's path is therefore:
restructureBadDebt(tokenId) so the protocol writes off part of the debt.redeem(..., 1e18, ...) and recover the same NFT because the code checks only for zero remaining debt, not whether collateral was already consumed by a prior debt-forgiveness event.The incident evidence matches this exactly. The decoded receipt for transaction 0xde903046b5cdf27a5391b771f41e645e9cc670b649f7b87b1524fc4076f45983 shows:
Borrow token_id=255 borrow_amount_wei=201595425653150513986
RestructureDebt token_id=255 repaid_by_writeoff_wei=141505102074743477723 account_borrows_after_wei=60090323578407036263
RestructureBadDebt token_id=255 post_liquidation_collateral_ratio=298073844600987108
Borrow token_id=255 repay_amount_wei=60090323578407036263 account_borrows_after_wei=0
Redeem token_id=255 percentage=1000000000000000000 redeem_token_id=255
Those values are the on-chain confirmation that the protocol accepted a write-off of 141.505102074743477723 WETH, then accepted repayment of only the post-haircut residual 60.090323578407036263 WETH, and finally released the full collateral tokenId 255.
The attacker cluster contains:
0xe3223f7e3343c2c8079f261d59ee1e513086c7c3, which deployed the helper and sent the exploit transaction0x98e938899902217465f17cf0b76d12b3dca8ce1b, which executed the exploit0xe9f853d2616ac6b04e5fc2b4be6eb654b9f224cd, embedded in helper construction and funded at the end of the exploitThe helper deployment transaction was 0x2b71b54f9f9543b9e55bd18a4d91b520c5b7e7d53f8ace256adf20df7e1e506a at block 29437393. The actual exploit realization was a single public Base transaction, 0xde903046b5cdf27a5391b771f41e645e9cc670b649f7b87b1524fc4076f45983, included at block 29437439.
The on-chain execution flow is:
255 in TokenizedUniswapV3Position.201.595425653150513986 WETH from the WETH borrowable.restructureBadDebt(255), which triggered the write-down.60.090323578407036263 WETH.The decoded event summary and balance diff pin down the final extraction:
{
"weth_borrowable_balance_delta_wei": "-34596457958884485813",
"attacker_eoa_native_fee_delta_wei": "-1498054501742780",
"attacker_profit_recipient_native_gain_wei": "34596457958884485813"
}
That is the ACT predicate in concrete terms: the victim borrowable loses WETH cash, and the attacker cluster realizes the same value minus transaction fees.
The directly measured protocol loss is on the Impermax V3 WETH borrowable. The collected balance diff shows that the borrowable's WETH balance decreased by 34596457958884485813 wei during the exploit transaction. The attacker recipient gained the same amount as native ETH, while the funding EOA paid 1498054501742780 wei in gas, producing 34594959904382743033 wei of net cluster profit.
The exploit also forced a debt haircut of 141505102074743477723 wei through the restructure path. That haircut is the lender-socialized loss event that made the later collateral recovery possible. In economic terms, Impermax absorbed the debt reduction while the borrower retained the collateral upside and then monetized it.
0xde903046b5cdf27a5391b771f41e645e9cc670b649f7b87b1524fc4076f459830x2b71b54f9f9543b9e55bd18a4d91b520c5b7e7d53f8ace256adf20df7e1e506a0xc1d49fa32d150b31c4a5bf1cbf23cf7ac99eaf7d0x5d93f216f17c225a8b5ffa34e74b7133436281ee0xa68f6075ae62ebd514d1600cb5035fa0e2210ef8