Skip to content

Commit 9dd75ea

Browse files
committed
fix: derive band settlement LTV frame before repay to close reentrancy drain
The offer-band pre-liquidation guard re-read live position state after the Morpho repay, whose onPreLiquidate callback (plus permissionless repay / supplyCollateral) could drain the position through the reentrant proportional path and re-supply a dust atom so the post-repay check passed. Compute the settled frame deterministically from the fill amounts before the repay (reproducing Morpho shares-mode rounding), so no callback can move it.
1 parent 1783525 commit 9dd75ea

2 files changed

Lines changed: 191 additions & 31 deletions

File tree

‎src/borrow/MorphoBorrowPosition.sol‎

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -419,20 +419,20 @@ contract MorphoBorrowPosition is IBorrowPosition, IBorrowOffers, Initializable,
419419
///
420420
/// Settlement guard: the walk's per-fill checks run against a snapshot of Morpho's totals,
421421
/// and on a virtual-share boundary the settled totals can round the remaining debt such
422-
/// that an accepted dust-sized fill leaves the LTV flat or higher (and repeated fills could
423-
/// walk it past `liquidationLtv` into the proportional path). After the repay settles, the
424-
/// position is re-quoted at the entry price and the call reverts unless the LTV strictly
425-
/// decreased ({_checkLtvReduced}); with the entry LTV at or below `liquidationLtv`, a
426-
/// strict decrease also keeps the settled LTV within the band's bound.
422+
/// that an accepted dust-sized fill leaves the LTV flat or higher (and, at the band's upper
423+
/// edge, tips the floored health check into the proportional path). The call reverts unless
424+
/// the fill strictly decreased the LTV ({_checkLtvStrictlyReduced}); with the entry LTV at or
425+
/// below `liquidationLtv`, a strict decrease also keeps the settled LTV within the band's
426+
/// bound. The guard runs before the repay on a frame derived from the fill amounts, so a
427+
/// liquidator callback cannot manipulate the state it reads.
427428
function _offerPreLiquidate(address borrower, uint256 seizedAssets, uint256 repaidShares, bytes calldata data)
428429
internal
429430
returns (uint256, uint256)
430431
{
431-
// One oracle read for the whole call: the walk, the entry quote and the settlement guard all
432-
// price collateral identically, so a mid-call price move (e.g. from the liquidator callback)
433-
// cannot skew the entry/exit comparison.
432+
// One oracle read for the whole call: the walk and the settlement guard price collateral
433+
// identically, so a mid-call price move (e.g. from the liquidator callback) cannot skew the
434+
// entry/exit comparison.
434435
uint256 price = IOracle(_borrowPositionStorage().marketParams.oracle).price();
435-
(uint256 entryDebtValue, uint256 entryCollateralValue) = _positionLtvFrame(price);
436436

437437
// The walk reads market/position once (post-accrual, so exact) and applies all offer mutations
438438
// as effects before the repay below. Scoped in a helper to keep this function's stack shallow.
@@ -442,9 +442,15 @@ contract MorphoBorrowPosition is IBorrowPosition, IBorrowOffers, Initializable,
442442
// gives liquidators a clear signal when the band is entered but nothing is fillable.
443443
if (totalSeized == 0 || totalDebtShares == 0) revert LibBorrowErrors.NoConsumableOffer();
444444

445-
uint256 repaidAssets = _settleOfferRepay(borrower, totalSeized, totalDebtShares, data);
445+
// Settlement guard, evaluated BEFORE the repay on the frame DERIVED from the fill amounts, so a
446+
// liquidator callback cannot move the state it reads. The Morpho `repay` withdraws the seized
447+
// collateral and then hands control to `onPreLiquidate`; since `repay` and `supplyCollateral`
448+
// are permissionless, a post-repay live re-quote could be neutralised by a callback that drains
449+
// the position through the proportional path and re-supplies a dust atom, letting a fill that
450+
// does not de-risk the position stand. The derived frame is immune to that.
451+
_checkLtvStrictlyReduced(price, totalSeized, totalDebtShares);
446452

447-
_checkLtvReduced(price, entryDebtValue, entryCollateralValue);
453+
uint256 repaidAssets = _settleOfferRepay(borrower, totalSeized, totalDebtShares, data);
448454

449455
return (totalSeized, repaidAssets);
450456
}
@@ -460,30 +466,44 @@ contract MorphoBorrowPosition is IBorrowPosition, IBorrowOffers, Initializable,
460466
(repaidAssets,) = MORPHO.repay(_borrowPositionStorage().marketParams, 0, totalDebtShares, borrower, callbackData);
461467
}
462468

463-
/// @dev Quotes the position's debt and collateral values at `price` from the current Morpho
464-
/// state (post-accrual totals; debt rounds up, collateral rounds down, matching
465-
/// {_isHealthy}). Called by {_offerPreLiquidate} before the consume and after the repay
466-
/// settles, so the two frames differ only by the fill's real effect on Morpho's totals.
467-
function _positionLtvFrame(uint256 price) internal view returns (uint256 debtValue, uint256 collateralValue) {
469+
/// @dev Reverts {LibBorrowErrors.LtvNotReduced} unless the fill strictly lowers the position LTV:
470+
/// `exitDebt / exitCollateral < entryDebt / entryCollateral`, cross-multiplied to
471+
/// `exitDebt * entryCollateral < entryDebt * exitCollateral` so the comparison is exact.
472+
///
473+
/// The exit frame is DERIVED from the fill amounts against the current (pre-repay) Morpho
474+
/// snapshot rather than re-read live after the repay, so it measures the fill alone and no
475+
/// liquidator callback can move it. It reproduces Morpho's shares-mode `repay` settlement
476+
/// exactly: the repaid assets round up, the market totals drop by (`repaidAssets`,
477+
/// `totalDebtShares`) with a saturating subtraction on the assets (Morpho's `repaidAssets`
478+
/// may exceed `totalBorrowAssets` by one), and the remaining debt rounds up on the reduced
479+
/// totals. Interest was accrued at entry and the in-flight repay re-accrues with zero elapsed
480+
/// time, so these totals equal the settled ones; the derived frame is therefore the exact
481+
/// state a post-repay quote would read absent a callback (debt rounds up, collateral rounds
482+
/// down, matching {_isHealthy}).
483+
///
484+
/// The product goes through `fullMulDiv`'s 512-bit intermediate rather than a plain
485+
/// multiplication: the collateral values are price-scaled (`price` is an unbounded oracle
486+
/// value at 1e36 scale) and can exceed 128 bits, so the direct product can overflow 256 bits
487+
/// and checked multiplication would turn a legitimate fill into a spurious revert. Flooring
488+
/// keeps it exact (`floor(a * b / d) >= c <=> a * b >= c * d` for integers). `entryDebtValue`
489+
/// and `totalDebtShares` are non-zero in the band (the position has debt; the fill repaid at
490+
/// least one share).
491+
function _checkLtvStrictlyReduced(uint256 price, uint256 totalSeized, uint256 totalDebtShares) internal view {
468492
Id _marketId = _borrowPositionStorage().marketId;
469493
Position memory position = MORPHO.position(_marketId, address(this));
470494
Market memory market = MORPHO.market(_marketId);
471-
debtValue =
472-
uint256(position.borrowShares).toAssetsUp(uint256(market.totalBorrowAssets), uint256(market.totalBorrowShares));
473-
collateralValue = uint256(position.collateral).mulDiv(price, ORACLE_PRICE_SCALE);
474-
}
495+
uint256 totalBorrowAssets = uint256(market.totalBorrowAssets);
496+
uint256 totalBorrowShares = uint256(market.totalBorrowShares);
497+
498+
uint256 entryDebtValue = uint256(position.borrowShares).toAssetsUp(totalBorrowAssets, totalBorrowShares);
499+
uint256 entryCollateralValue = uint256(position.collateral).mulDiv(price, ORACLE_PRICE_SCALE);
500+
501+
// Settled frame derived from the fill (matches Morpho's shares-mode repay rounding).
502+
uint256 repaidAssets = totalDebtShares.toAssetsUp(totalBorrowAssets, totalBorrowShares);
503+
uint256 exitDebtValue = (uint256(position.borrowShares) - totalDebtShares)
504+
.toAssetsUp(totalBorrowAssets.zeroFloorSub(repaidAssets), totalBorrowShares - totalDebtShares);
505+
uint256 exitCollateralValue = (uint256(position.collateral) - totalSeized).mulDiv(price, ORACLE_PRICE_SCALE);
475506

476-
/// @dev Reverts {LibBorrowErrors.LtvNotReduced} unless the settled position's LTV is strictly
477-
/// below the entry frame's: `exitDebt / exitCollateral < entryDebt / entryCollateral`,
478-
/// cross-multiplied to `exitDebt * entryCollateral < entryDebt * exitCollateral` so the
479-
/// comparison is exact. The product goes through `fullMulDiv`'s 512-bit intermediate
480-
/// rather than a plain multiplication: the collateral values are price-scaled (`price` is
481-
/// an unbounded oracle value at 1e36 scale) and can exceed 128 bits, so the direct product
482-
/// can overflow 256 bits and checked multiplication would turn a legitimate fill into a
483-
/// spurious revert. Flooring keeps it exact (`floor(a * b / d) >= c <=> a * b >= c * d`
484-
/// for integers). `entryDebtValue > 0` in the band (the position has debt, rounded up).
485-
function _checkLtvReduced(uint256 price, uint256 entryDebtValue, uint256 entryCollateralValue) internal view {
486-
(uint256 exitDebtValue, uint256 exitCollateralValue) = _positionLtvFrame(price);
487507
if (FixedPointMathLib.fullMulDiv(exitDebtValue, entryCollateralValue, entryDebtValue) >= exitCollateralValue) {
488508
revert LibBorrowErrors.LtvNotReduced();
489509
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
pragma solidity ^0.8.20;
3+
4+
import {console2} from "forge-std/console2.sol";
5+
import {MorphoBorrowPositionOffersTest} from "./MorphoBorrowPositionOffers.t.sol";
6+
import {MorphoBorrowPosition} from "src/borrow/MorphoBorrowPosition.sol";
7+
import {IPreLiquidationCallback} from "src/interfaces/borrow/IPreliquidationCallback.sol";
8+
import {LibBorrowErrors} from "src/libs/borrow/LibBorrowErrors.sol";
9+
import {IMorpho, Id, MarketParams, Position, Market} from "lib/morpho-blue/src/interfaces/IMorpho.sol";
10+
import {MockERC20} from "test/mock/MockERC20.sol";
11+
import {SharesMathLib} from "lib/morpho-blue/src/libraries/SharesMathLib.sol";
12+
13+
/// @notice Regression test for the Cantina reentrancy finding on the band settlement guard.
14+
/// The reviewer's exploit ran end-to-end against the real Morpho + MorphoBorrowPosition: a
15+
/// liquidator's `onPreLiquidate` callback re-entered the PROPORTIONAL path, repaid all
16+
/// remaining shares (seizing the rest of the collateral), supplied a few collateral atoms
17+
/// back on behalf of the position, and returned with `exitDebt = 0`, so a post-repay live
18+
/// `_checkLtvReduced` passed and the whole owner position was drained at the proportional
19+
/// discount.
20+
///
21+
/// Precondition: the position sits at `margin == 0` (its floored borrowing capacity at
22+
/// `liquidationLtv` exactly equals its debt, i.e. it is at the offer-band/proportional
23+
/// boundary). At `margin == 0` a one-atom "flat LTV" offer fill (the 3F-469 residual) ticks
24+
/// the floored `_isHealthy(liquidationLtv)` check false at the callback point, which is what
25+
/// made the reentrant proportional path reachable.
26+
///
27+
/// The fix ({_checkLtvStrictlyReduced}) evaluates the guard BEFORE the repay on a frame
28+
/// derived from the fill amounts, so the non-de-risking dust fill is rejected up front and
29+
/// the callback never runs. This test asserts the exploit is blocked.
30+
contract ReentrancyGuardBypassPoC is MorphoBorrowPositionOffersTest {
31+
using SharesMathLib for uint256;
32+
33+
function test_poc_confirmExploitAtBoundaryPrice() public {
34+
_enterBand(0.7e18); // establish the position (collateral + borrow)
35+
36+
// Binary-search the smallest price that dispatches to the offer band (isHealthy at
37+
// liquidationLtv). At that price the floored borrowing capacity first reaches the debt, so
38+
// `margin == 0`. Higher price => lower LTV => healthier, so isHealthy(liqLtv) is monotonic.
39+
uint256 lo = BORROW * SCALE / COLLATERAL * 1e18 / uint256(LIQ_LTV);
40+
uint256 hi = lo * 2;
41+
for (uint256 it; it < 256 && hi - lo > 1; ++it) {
42+
uint256 mid = lo + (hi - lo) / 2;
43+
oracle.setPrice(mid);
44+
if (pos.isHealthy(uint256(LIQ_LTV))) hi = mid;
45+
else lo = mid;
46+
}
47+
oracle.setPrice(hi); // offer-band dispatch, closest to the boundary
48+
assertTrue(pos.isHealthy(uint256(LIQ_LTV)) && !pos.isHealthy(uint256(SAFE_LTV)), "offer band at entry");
49+
50+
_proposeAtPrice(_positionShares() / 2, 1.2e18);
51+
_warpActive();
52+
53+
uint256 ownerCollBefore = _positionCollateral();
54+
PureExploitLiquidator attacker =
55+
new PureExploitLiquidator(pos, morpho, marketParams, marketId, loanToken, collateralToken, LIQ_LTV);
56+
loanToken.setBalance(address(attacker), 100_000_000e18);
57+
collateralToken.setBalance(address(attacker), 1_000e18);
58+
59+
uint256 attackerCollBefore = collateralToken.balanceOf(address(attacker));
60+
61+
(bool ok, bytes4 sel) = attacker.attackSeize(1); // one-atom seize target => the 3F-469 residual fill
62+
63+
uint256 collGained = collateralToken.balanceOf(address(attacker)) - attackerCollBefore;
64+
65+
console2.log("outerOk=%s exploitRan=%s", ok, attacker.exploitRan());
66+
console2.log(
67+
"ownerCollBefore=%e ownerCollAfter=%e ownerDebtAfter=%e",
68+
ownerCollBefore,
69+
_positionCollateral(),
70+
_positionShares()
71+
);
72+
console2.log("attacker collateralGained=%e", collGained);
73+
74+
// Fix regression: the settlement guard now runs BEFORE the repay on a derived frame, so the
75+
// non-de-risking dust fill is rejected up front. The outer call reverts LtvNotReduced, the
76+
// callback (and its reentrant proportional drain) never runs, and the owner is untouched.
77+
assertFalse(ok, "exploit blocked: outer preLiquidate reverts");
78+
assertEq(sel, LibBorrowErrors.LtvNotReduced.selector, "reverts with LtvNotReduced before settlement");
79+
assertFalse(attacker.exploitRan(), "callback never reached: guard runs before the repay");
80+
assertEq(_positionCollateral(), ownerCollBefore, "owner position untouched");
81+
assertEq(collGained, 0, "attacker seized nothing");
82+
}
83+
}
84+
85+
/// @notice Liquidator that runs the reviewer's exploit steps: if the reentrant proportional path is
86+
/// reachable at the callback (position unhealthy at liquidationLtv), repay all remaining
87+
/// shares (seizing the rest of the collateral) then supply a few collateral atoms back on
88+
/// behalf of the position so the outer exit frame reads (debt=0, collateral>0).
89+
contract PureExploitLiquidator is IPreLiquidationCallback {
90+
MorphoBorrowPosition internal pos;
91+
IMorpho internal morpho;
92+
MarketParams internal mp;
93+
Id internal id;
94+
MockERC20 internal loanToken;
95+
MockERC20 internal collateralToken;
96+
uint128 internal liqLtv;
97+
bool internal entered;
98+
bool public exploitRan;
99+
100+
constructor(
101+
MorphoBorrowPosition _pos,
102+
IMorpho _morpho,
103+
MarketParams memory _mp,
104+
Id _id,
105+
MockERC20 _loan,
106+
MockERC20 _coll,
107+
uint128 _liqLtv
108+
) {
109+
pos = _pos;
110+
morpho = _morpho;
111+
mp = _mp;
112+
id = _id;
113+
loanToken = _loan;
114+
collateralToken = _coll;
115+
liqLtv = _liqLtv;
116+
loanToken.approve(address(_pos), type(uint256).max);
117+
loanToken.approve(address(_morpho), type(uint256).max);
118+
collateralToken.approve(address(_morpho), type(uint256).max);
119+
}
120+
121+
function attackSeize(uint256 seizeTarget) external returns (bool ok, bytes4 sel) {
122+
try pos.preLiquidate(address(pos), seizeTarget, 0, abi.encode("go")) {
123+
ok = true;
124+
} catch (bytes memory reason) {
125+
if (reason.length >= 4) sel = bytes4(reason);
126+
}
127+
}
128+
129+
function onPreLiquidate(uint256, bytes calldata) external override {
130+
if (entered) return;
131+
entered = true;
132+
if (pos.isHealthy(uint256(liqLtv))) return; // proportional path NOT reachable
133+
uint256 rem = uint256(morpho.position(id, address(pos)).borrowShares);
134+
pos.preLiquidate(address(pos), 0, rem, ""); // reentrant proportional: drain all collateral
135+
// Supply enough atoms that the exit-collateral VALUE is strictly positive (one atom rounds to
136+
// zero value when price/scale < 1, which would trip the guard's 0 >= 0 branch).
137+
morpho.supplyCollateral(mp, 1e9, address(pos), "");
138+
exploitRan = true;
139+
}
140+
}

0 commit comments

Comments
 (0)