tradi3_ Kurt Landman
Public report archive

Morpho Midnight T3-001

MidnightBundles minUnits check can pass before callback-created loss slashes received credit

MidnightBundles.buyWithAssetsTargetAndWithdrawCollateral() validates minUnits against raw pre-callback units returned by Midnight.take() before syncing the taker's position against same-transaction bad-debt liquidations triggered in the seller callback.

Severity
Low
Outcome
confirmed
Competition
Morpho Midnight
Vulnerability class
callback ordering
Published

Summary

Confirmed Low finding in the Cantina Morpho Midnight public competition (finding #652, morpho-org/midnight @ 7538c438513622721e23a94676b93a335b83dace).

MidnightBundles.buyWithAssetsTargetAndWithdrawCollateral() documents that “The taker will gain at least minUnits.” However, the periphery contract accumulates the raw unitsToTake returned by Midnight.take() and checks require(filledUnits >= minUnits) without syncing the taker’s position against the market’s post-callback lossFactor.

During a sell-offer fill, Midnight.take() credits the buyer first and then invokes the seller callback before returning. A sell-offer maker can use that callback to liquidate a different unhealthy borrower in the same market, raising lossFactor inside the same transaction. The bundle still counts the pre-slash unitsToTake and passes filledUnits >= minUnits, leaving the taker with a fraction of their specified minimum output once updatePosition() syncs their balance.

Affected code

  • src/periphery/MidnightBundles.sol:178: NatSpec states “The taker will gain at least minUnits.”
  • src/periphery/MidnightBundles.sol:208-225: computes unitsToTake, adds that raw value to filledUnits, and checks filledUnits >= minUnits.
  • src/Midnight.sol:408-418: credits the buyer’s position and updates market units before invoking callbacks.
  • src/Midnight.sol:458-476: executes the seller callback before take() returns to MidnightBundles.
  • src/Midnight.sol:626-640: increases the market’s lossFactor when liquidate() realizes bad debt on another borrower.
  • src/Midnight.sol:797-819: slashes the buyer’s credited units by the updated lossFactor when updatePosition() runs.

Because the seller liquidation lock only prevents liquidating the seller during take(), the seller callback is free to liquidate a separate underwater borrower in the same market.

Proof of concept

Running BundlesMinUnitsSlashedBySellerCallbackPoC.t.sol with Foundry:

forge test --match-contract BundlesMinUnitsSlashedBySellerCallbackPoC -vv

Observed output:

[PASS] test_bundleMinUnitsPassesBeforeFreshCreditIsSlashedBySellerCallback() (gas: 1266731)
Logs:
  lender paid loan tokens: 100000000000000000000
  bundle minUnits argument: 100000000000000000000
  raw lender credit after bundle: 100000000000000000000
  post-update lender credit: 30600649350649350667
  market lossFactor: 236153753017372066706982153190265421615

The taker pays 100e18 loan tokens and sets minUnits = 100e18. The bundle call succeeds without reverting, but after updatePosition() the taker holds only 30.60e18 units (a 69.4% reduction below minUnits).

Sync the taker’s position after the loop in buyWithAssetsTargetAndWithdrawCollateral() and verify that the net post-sync credit delta satisfies minUnits (or revert if lossFactor increases during the bundle execution).