Calculated from recorded token losses using historical USD prices at the incident time.
0x00c503b595946bccaea3d58025b5f9b3726177bbdc9674e634244135282116c70x56a201b872b50bbdee0021ed4d1bb36359d291edEthereumPrisma Finance's MigrateTroveZap migration helper could be driven through an arbitrary DebtToken.flashLoan callback, letting any external caller execute delegated trove operations for accounts that had previously approved the zap as a delegate. In the incident transaction 0x00c503b595946bccaea3d58025b5f9b3726177bbdc9674e634244135282116c7, the attacker used that path to close the victim trove, reopen it with far less collateral, and recycle the stolen wstETH into the attacker's own trove before exiting with profit.
The root cause was that MigrateTroveZap.onFlashLoan authenticated only msg.sender == address(debtToken) and then decoded fully attacker-controlled callback data into delegated BorrowerOperations.closeTrove and BorrowerOperations.openTrove calls. The callback did not verify that the migration originated from migrateTrove, that the specified account initiated the action, or that the callback parameters matched the account's real trove state.
Prisma BorrowerOperations supports delegated actions through callerOrDelegated(account). If an account has set isApprovedDelegate[account][delegate] = true, the delegate can call functions such as openTrove and closeTrove on that account's behalf.
MigrateTroveZap is meant to help a user migrate a trove between compatible trove managers. Its intended public entrypoint is migrateTrove, which reads the caller's current trove, validates source and destination managers, and then requests a DebtToken flash loan with self-generated callback data.
DebtToken.flashLoan is itself publicly callable. That means any attacker can choose a receiver contract and arbitrary callback bytes, so any callee that trusts only the fact that DebtToken invoked it is exposed if the callback performs privileged actions.
The vulnerability is an unauthenticated callback executing delegated state transitions. MigrateTroveZap.onFlashLoan accepted any callback initiated by DebtToken, decoded (account, troveManagerFrom, troveManagerTo, maxFeePercentage, coll, upperHint, lowerHint), and immediately called borrowerOps.closeTrove(troveManagerFrom, account) followed by borrowerOps.openTrove(...). Because BorrowerOperations checks delegation against msg.sender, an account that had approved MigrateTroveZap was exposed to any third party capable of reaching DebtToken.flashLoan.
The invariant should have been: a migration helper may only close and reopen an approved account's trove when that same account initiated the migration and when the callback parameters match the helper's internally prepared migration state. The code-level breakpoint is the missing binding between migrateTrove and onFlashLoan. No pending migration was recorded, no caller identity was bound into storage, and no callback parameter sanity checks ensured the collateral and manager inputs matched the intended migration path.
The incident trace shows the attacker used this public callback twice. The first callback forcibly reopened the victim trove with only 463184447350099685758 wei of wstETH collateral. The second callback refinanced the attacker's own trove using the stolen collateral, after which the attacker closed that trove and retained 1281797208306130557587 wei of wstETH.
The verified MigrateTroveZap source shows the vulnerable callback directly:
function onFlashLoan(address, address, uint256 amount, uint256 fee, bytes calldata data) external returns (bytes32) {
require(msg.sender == address(debtToken), "!DebtToken");
(address account, address troveManagerFrom, address troveManagerTo, uint256 maxFeePercentage, uint256 coll, address upperHint, address lowerHint) =
abi.decode(data, (address, address, address, uint256, uint256, address, address));
uint256 toMint = amount + fee;
borrowerOps.closeTrove(troveManagerFrom, account);
borrowerOps.openTrove(troveManagerTo, account, maxFeePercentage, coll, toMint, upperHint, lowerHint);
return _RETURN_VALUE;
}
By contrast, the intended migrateTrove path validates manager compatibility, reads (coll, debt) from troveManagerFrom.getTroveCollAndDebt(msg.sender), and then constructs the callback itself. That protection is bypassed if an attacker calls DebtToken.flashLoan directly with receiver = MigrateTroveZap.
BorrowerOperations makes the delegated execution possible:
modifier callerOrDelegated(address _account) {
require(msg.sender == _account || isApprovedDelegate[_account][msg.sender], "Delegate not approved");
_;
}
Both openTrove and closeTrove use callerOrDelegated(account), and openTrove transfers collateral from msg.sender while minting debt back to msg.sender. That means when MigrateTroveZap is the caller, the zap both spends collateral it holds and receives the new debt tokens created by the reopen.
The incident trace confirms the exact exploit path. The first malicious callback appears as:
DebtToken::flashLoan(MigrateTroveZap, DebtToken, 1442100643475620087665721, ...)
MigrateTroveZap::onFlashLoan(...)
BorrowerOperations::closeTrove(TroveManager, 0x56a201...291ed)
BorrowerOperations::openTrove(TroveManager, 0x56a201...291ed, ..., 463184447350099685758, ...)
The trace then shows WstETH::transfer sending 1745081655656230243345 wei of collateral to MigrateTroveZap, followed by WstETH::transferFrom moving only 463184447350099685758 wei back into the trove manager for the forced reopen. The balance diff artifact records the resulting loss and profit:
{
"victim_trove_manager_delta": "-1281897208306130557587",
"migrate_zap_wsteth_delta": "100000000000000000",
"attacker_contract_wsteth_delta": "1281797208306130557587"
}
Those values match the exploit theory exactly: most of the victim collateral was left outside the victim trove, 0.1 wstETH remained on the zap, and the attacker finished with the stolen remainder after unwinding its own trove.
The exploit used one Ethereum mainnet transaction at block 19532297, sent by EOA 0x7e39e3b3ff7adef2613d5cc49558eab74b9a4202 to attacker contract 0xd996073019c74b2fb94ead236e32032405bc027c.
First, the attacker borrowed 1 wstETH from Balancer and opened its own trove through BorrowerOperations, minting about 2000 mkUSD and approving MigrateTroveZap as its own delegate. This created an attacker-controlled trove that could later absorb the stolen collateral.
Second, the attacker called DebtToken.flashLoan directly with receiver = MigrateTroveZap and callback bytes naming victim 0x56a201b872b50bbdee0021ed4d1bb36359d291ed. The trace shows MigrateTroveZap closing that victim trove and reopening it on the same TroveManager with only 463184447350099685758 wei of collateral, despite the original trove having over 1745 wstETH before the close.
Third, the attacker repeated the same public callback pattern against its own approved trove. The second callback closed the attacker trove, reopened it with 1282797208306130557587 wei of collateral sourced from the zap's temporarily held funds, and minted enough mkUSD to settle the flash-loan leg.
Finally, the attacker closed its own trove directly, recovered the wstETH, repaid the Balancer loan, and retained 1281797208306130557587 wei of wstETH. The gas-paying EOA spent 83396605653346528 wei in gas according to the metadata artifact, which is negligible relative to the realized wstETH gain.
The primary victim was the approved Prisma trove owner at 0x56a201b872b50bbdee0021ed4d1bb36359d291ed. Their trove remained active after the exploit but was forcibly reopened with far less collateral, causing a collateral loss of 1281897208306130557587 wei of wstETH.
The attacker contract at 0xd996073019c74b2fb94ead236e32032405bc027c realized 1281797208306130557587 wei of wstETH, while MigrateTroveZap retained 100000000000000000 wei of wstETH. The root cause therefore exposed every trove owner who had approved MigrateTroveZap as a delegate, not just the single account exploited in this transaction.
0x00c503b595946bccaea3d58025b5f9b3726177bbdc9674e634244135282116c70x7e39e3b3ff7adef2613d5cc49558eab74b9a42020xd996073019c74b2fb94ead236e32032405bc027c0x56a201b872b50bbdee0021ed4d1bb36359d291edMigrateTroveZap: 0xcC7218100da61441905e0c327749972e3CBee9EEBorrowerOperations: 0x72c590349535ad52e6953744cb2a36b409542719TroveManager: 0x1cc79f3f47bfc060b6f761fcd1afc6d399a968b6