Calculated from recorded token losses using historical USD prices at the incident time.
0xAe7b6514Af26BcB2332FEA53B8Dd57bc13A7838EOptimism0x4a6E0fAd381d992f9eB9C037c8F78d788A9e8991OptimismMO / Loan Protocol on Optimism exposed an anyone-can-take drain after protocol administration transferred two critical privileges to the public Loan contract: MO token ownership and the factory feeToSetter role. Once those transfers were live, any borrower could enter Loan.borrow(), temporarily seize pair-router authority over the MO/USDT pair, pull MO directly out of reserves with pair-only claim() calls, resync the depleted balances, and then borrow or swap against the manipulated price curve. The seed transaction 0x4ec3061724ca9f0b8d400866dd83b92647ad8c943a1c0ae9ae6c9bd1ef789417 realized the drain and extracted 745302919887 raw USDT units across the attacker cluster.
The incident centered on four public protocol components on Optimism:
Loan at 0xAe7b6514Af26BcB2332FEA53B8Dd57bc13A7838E0x61445Ca401051c86848ea6b1fAd79c5527116AA10x4a6E0fAd381d992f9eB9C037c8F78d788A9e89910x30B2901AF5d4DB361d4f49645D54Ca8E0f5de849The MO token is not a plain ERC20. Its owner can whitelist transfer senders and set the pair address:
function setWhitelist(address user, bool state) public onlyOwner {
whitelist[user] = state;
}
0xed4dd5b534e5f4217cf8ec3924691858a895d613c5768ef4d186ef4f1bdf4cfc0x47bf821ed83a6e4a8d46cc1b10c0b9f7eb5b35ddd83be3a509961d14b00e51dc0x23dedff08b399e148874cac20026f4a12cfa5b4824cd1af4650f2c8ef02352660x4ec3061724ca9f0b8d400866dd83b92647ad8c943a1c0ae9ae6c9bd1ef789417The custom pair also exposes non-standard reserve-control surfaces to the factory feeToSetter and the active router:
function setRouter(address _router) external {
require(msg.sender == IUniswapV2Factory(factory).feeToSetter(), "UniswapV2: FORBIDDEN");
router = _router;
}
function claim(address token, address to, uint256 amount) external {
require(msg.sender == router, "UniswapV2: FORBIDDEN");
_safeTransfer(token, to, amount);
}
Those capabilities became dangerous after the admin history moved both privileges into Loan. The factory state at block 117270132 already shows:
0xAe7b6514Af26BcB2332FEA53B8Dd57bc13A7838E
The MO token admin history likewise records transferOwnership(address) to Loan, and the factory history records setFeeToSetter(address) to Loan.
The root cause was a public borrow path that directly exercised privileges intended to remain administrative. Loan.borrow() first trusts the pair reserves through price(), then transfers USDT to the borrower, then uses factory-level and token-owner-level authority to mutate the same reserve state that the price calculation relied on. Specifically, it can whitelist any borrower in MO, retake router authority on the pair, call pair-only claim() to burn 90% of posted MO and route 10% into Loan, and finally call sync() to commit the depleted reserves. Because redeemRate remained 10000 and duration 0 accrues zero interest in practice, the attacker could repeatedly redeem the previous borrow, recover the MO principal, and re-enter the same path with a higher quoted price. The resulting staircase increased Loan.price() loop by loop while draining both Loan USDT and the pair’s USDT liquidity. This is an ACT condition because no privileged attacker secret was needed after the public role transfers were in place.
The decisive code path is in Loan.borrow():
if (IToken(borrowToken).whitelist(msg.sender) == false) {
IToken(borrowToken).setWhitelist(msg.sender, true);
}
IApproveProxy(approveProxy).claim(borrowToken, msg.sender, address(this), amount);
uint256 total = (amount * price() * (BASE - borrowOverCollateral)) / BASE / 1e4;
IERC20(supplyToken).safeTransfer(msg.sender, total);
IUniswapV2Pair(pair).setRouter(address(this));
IUniswapV2Pair(pair).claim(borrowToken, BURN, (amount * burnRate) / BASE);
IUniswapV2Pair(pair).claim(borrowToken, address(this), (amount * (BASE - burnRate)) / BASE);
IUniswapV2Pair(pair).sync();
IUniswapV2Pair(pair).setRouter(router);
This breaks the core invariant that the AMM reserves used by Loan.price() must not be mutable through the same borrower action that consumes the quoted price. The trace of the seed transaction confirms the exact violating sequence. Near the end of the exploit, the helper executes:
Loan::borrow(1000101, 0)
pair::setRouter(Loan)
pair::claim(MO, 0x000...dEaD, 900090)
pair::claim(MO, Loan, 100010)
pair::sync()
pair::setRouter(router)
The exploit also relies on Loan.redeem() returning the full posted MO amount:
uint256 intere = interest(msg.sender, index);
uint256 amount = (order.amount * redeemRate) / BASE;
IApproveProxy(approveProxy).claim(supplyToken, msg.sender, address(this), order.total + intere);
IERC20(borrowToken).safeTransfer(msg.sender, amount);
With redeemRate = 10000, the borrower recovers the full MO principal, and because the trace shows interest: 0 on the zero-duration orders, the borrow/redeem loop can be repeated until the pair is nearly empty.
The exploit path had two phases.
First, the privilege window was established by protocol-admin transactions:
0xc7dfc15db650cabbc6af42fe8a55e49b143eee40f51fa6daf8a80a778459a90f: MO ownership moved again as part of the handoff path.0xadc4968e3ef239fd3b2c14a7905ee32c761ae59f27ca458e1a8f634f65ee700d: MO ownership transferred to Loan.0x203d0c5d9925f6632eab4d12dd63322939edbc0b37541fee1386c853753501dd: factory setFeeToSetter(Loan).Second, the attacker cluster prepared helper state and then drained the system through the public flow:
0x95b7023c7a451e4064c6ba11fbea4a242d453dc1d8a4a1ca5fe347855892c5ec: helper deployment0x11d8b85d2893457d1f8806aeec818ba417055c57e06b2ad6fea275a8aa8f3e000xed4dd5b534e5f4217cf8ec3924691858a895d613c5768ef4d186ef4f1bdf4cfc0x47bf821ed83a6e4a8d46cc1b10c0b9f7eb5b35ddd83be3a509961d14b00e51dc0x23dedff08b399e148874cac20026f4a12cfa5b4824cd1af4650f2c8ef02352660x4ec3061724ca9f0b8d400866dd83b92647ad8c943a1c0ae9ae6c9bd1ef789417: final drainInside the seed transaction, the trace shows hundreds of repeated Loan::borrow(..., 0) and Loan::redeem(...) pairs, each accompanied by pair::setRouter, two pair::claim calls, and pair::sync. Referral reward transfers to the attacker-controlled referrer contract further increased the MO available to continue the staircase. The transaction ends with two swaps through the restored public router, converting the manipulated MO side into USDT.
The seed transaction balance diff proves the realized loss:
{
"loan_usdt_delta": "-331465404544",
"pair_usdt_delta": "-413837515343",
"attacker_eoa_usdt_delta": "413837515341",
"attacker_helper_usdt_delta": "331465404544",
"pair_mo_delta": "-4475753629"
}
Measured in the reference asset, the adversary cluster gained 745302919887 raw USDT units (745,302.919887 USDT). The pair was left with only 1010001 raw MO units and 494240360 raw USDT units in the incident post-state, confirming near-total reserve depletion.
0x4ec3061724ca9f0b8d400866dd83b92647ad8c943a1c0ae9ae6c9bd1ef789417/workspace/session/artifacts/collector/seed/10/0x4ec3061724ca9f0b8d400866dd83b92647ad8c943a1c0ae9ae6c9bd1ef789417/balance_diff.json/workspace/session/artifacts/collector/iter_1/contract/10/0xae7b6514af26bcb2332fea53b8dd57bc13a7838e/src/Loan/contracts/Loan.sol/workspace/session/artifacts/collector/iter_1/contract/10/0x30b2901af5d4db361d4f49645d54ca8e0f5de849/src/UniswapV2Factory/contracts/uniswap/UniswapV2Pair.sol/workspace/session/artifacts/collector/seed/10/0x61445ca401051c86848ea6b1fad79c5527116aa1/src/Token.sol/workspace/session/artifacts/collector/iter_1/address/10/0x61445ca401051c86848ea6b1fad79c5527116aa1/normal_txs.json/workspace/session/artifacts/collector/iter_1/address/10/0x30b2901af5d4db361d4f49645d54ca8e0f5de849/normal_txs.json