0xd12016b25d7aef681ade3dc3c9d1a1cc12f35b2c99953ff0e0ee23a59454c4fe0xd9b45e2c389b6ad55dd3631abc1de6f2d2229847Optimism0x24592ed1ccf9e5ae235e24a932b378891313fb75Optimism0xce5e0e2bcf40a049a6e148f411a19419d0443607Optimism0x80472c6848015146fdc3d15cdf6dc11ca3cb3513OptimismOn Optimism block 129697251, tx 0xd12016b25d7aef681ade3dc3c9d1a1cc12f35b2c99953ff0e0ee23a59454c4fe let an unprivileged attacker drain four deployed MoonHacker strategy wallets that were holding leveraged Moonwell mUSDC positions. The attacker used a helper contract plus temporary USDC liquidity to force each victim wallet through MoonHacker's public Aave callback and to keep the redeemed USDC after repaying the temporary capital.
The root cause is that MoonHacker.executeOperation is externally callable and trusts attacker-controlled callback parameters. It does not verify msg.sender == address(POOL), does not verify initiator == address(this), and does not restrict the decoded mToken address, so any caller can make a victim wallet approve and call an attacker-chosen contract.
MoonHacker is a user-deployed strategy contract that opens and unwinds leveraged Moonwell positions with Aave flash loans. The intended operator model is owner-only: smartSupply, smartRedeem, withdrawals, and reward claims are all wrapped in onlyOwner.
The relevant contrast is visible in the verified source:
function smartSupply(address token, address mToken, uint256 amountToBorrow, uint256 amountToSupply) public onlyOwner() {
bytes memory params = abi.encode(SmartOperation.SUPPLY, mToken, amountToSupply);
POOL.flashLoanSimple(address(this), token, amountToBorrow, params, 0);
}
function smartRedeem(address token, address mToken) public onlyOwner() {
IMToken(mToken).borrowBalanceCurrent(address(this));
(uint err, uint amountToReedem, uint amountToRepay, uint exRateMantissa) = IMToken(mToken).getAccountSnapshot(address(this));
smartRedeemAmount(token, mToken, amountToReedem, amountToRepay);
}
The exploit matters because the real state transition logic lives in executeOperation, not in the wrappers. Once that callback is left unauthenticated, the owner-only surface is bypassed.
Moonwell reward claiming is part of the observed execution. During the forced redeem path, the victim contract calls COMPTROLLER.claimReward(address(this)), and Moonwell's Comptroller implementation permits claimReward(address holder) for an arbitrary holder:
function claimReward(address holder) public {
claimReward(holder, allMarkets);
}
The vulnerability is an unauthenticated callback with attacker-controlled target selection. In the verified MoonHacker source, executeOperation is declared external and performs no validation that the caller is the Aave pool or that the flash-loan initiator is the wallet itself. It decodes arbitrary user-supplied params into (operation, mToken, amount) and then directly uses mToken as the approval target and as the contract to invoke. In the REDEEM branch, this lets an attacker force a victim wallet to repay borrow, redeem mUSDC, and claim rewards. In the SUPPLY branch, this lets the attacker replace the expected Moonwell mToken with a malicious helper contract that receives an approval over the victim's freshly redeemed USDC and immediately consumes it with transferFrom. The broken invariant is that only the wallet owner, inside a self-initiated Aave flash-loan lifecycle, should be able to mutate a strategy wallet's borrow or collateral state or grant token-moving authority to third parties. Because the callback lacks that trust boundary, any unprivileged account can deterministically drain any MoonHacker wallet that still has reachable collateral.
The key breakpoint is the body of executeOperation in the verified MoonHacker source:
function executeOperation(
address token,
uint256 amountBorrowed,
uint256 premium,
address initiator,
bytes calldata params
) external returns (bool) {
(SmartOperation operation, address mToken, uint256 amountToSupplyOrReedem) =
abi.decode(params, (SmartOperation, address, uint256));
if (operation == SmartOperation.SUPPLY) {
uint256 totalSupplyAmount = amountBorrowed + amountToSupplyOrReedem;
IERC20(token).approve(mToken, totalSupplyAmount);
require(IMToken(mToken).mint(totalSupplyAmount) == 0, "mint failed");
require(IMToken(mToken).borrow(totalAmountToRepay) == 0, "borrow failed");
} else if (operation == SmartOperation.REDEEM) {
IERC20(token).approve(mToken, amountBorrowed);
require(IMToken(mToken).repayBorrow(amountBorrowed) == 0, "repay borrow failed");
require(IMToken(mToken).redeem(amountToSupplyOrReedem) == 0, "redeem failed");
COMPTROLLER.claimReward(address(this));
}
IERC20(token).approve(address(POOL), totalAmountToRepay);
return true;
}
Three missing checks make the exploit possible:
initiator == address(this) check.mToken target.The exploit transaction shows this exact misuse. The attacker first borrowed 883917967954 raw USDC units through Aave and then called each victim wallet directly. For victim 0xd9b45e...9847, the trace shows a forced redeem callback into the real Moonwell mUSDC market, followed by reward claiming:
0xD9B45e2c389b6Ad55dD3631AbC1de6F2D2229847::executeOperation(..., params=abi.encode(REDEEM, mUSDC, 2914299300544423))
FiatTokenV2_2::approve(MErc20Delegator, 485984781792)
FiatTokenV2_2::transferFrom(victim, MErc20Delegator, 485984781792)
Comptroller::claimReward(0xD9B45e2c389b6Ad55dD3631AbC1de6F2D2229847)
The attacker then immediately called the same victim wallet again, but this time pointed mToken at the malicious helper contract instead of Moonwell:
0xD9B45e2c389b6Ad55dD3631AbC1de6F2D2229847::executeOperation(..., params=abi.encode(SUPPLY, attacker_helper, 0))
FiatTokenV2_2::approve(attacker_helper, 595813435803)
attacker_helper::mint(595813435803)
attacker_helper::borrow(595813435803)
FiatTokenV2_2::transferFrom(victim, attacker_helper, 595813435803)
The same pattern repeats for 0x24592ed1...fb75, 0xce5e0e2b...3607, and 0x80472c68...3513. The balance diff corroborates the end state: all observed victim mUSDC balances in scope drop to zero, reward tokens are credited into victims during the forced redeem path, and the attacker EOA ends with a net 318987572368 raw USDC gain. This is a deterministic contract-level permission failure, not a pricing anomaly or privileged key compromise.
The adversary cluster identified from the transaction consists of:
0x36491840ebcf040413003df9fb65b6bc9a181f52, the sender and final profit recipient.0x3a6eaaf2b1b02ceb2da4a768cfeda86cff89b287, deployed and used as the flash-loan receiver plus malicious mToken.0x4e258f1705822c2565d54ec8795d303fdf9f768e, deployed earlier in the same transaction to bootstrap the helper.The victim strategy wallets are:
0xd9b45e2c389b6ad55dd3631abc1de6f2d22298470x24592ed1ccf9e5ae235e24a932b378891313fb750xce5e0e2bcf40a049a6e148f411a19419d04436070x80472c6848015146fdc3d15cdf6dc11ca3cb3513Their provenance summaries show they were created and previously operated by unrelated EOAs using the owner-only strategy methods such as smartSupply, confirming that the exploit targeted already-existing victim wallets rather than attacker-owned setups.
The on-chain exploit flow is:
0xd12016...c4fe.flashLoanSimple for 883917967954 raw USDC units.executeOperation(REDEEM, real_mUSDC, victimBalance) to force borrow repayment, collateral redemption, and reward claiming.executeOperation(SUPPLY, helper, 0) so the victim wallet approved the helper for the redeemed USDC and immediately called the helper's mint and borrow selectors.transferFrom, accumulated the drained USDC, repaid the temporary capital, and transferred the remaining profit to the attacker EOA.This flow is ACT-complete: it uses only public on-chain state, a permissionless helper deployment, and standard transaction execution. No privileged key, owner role, or private orderflow is required.
The validated measurable loss is 318987572368 raw USDC units, or 318,987.572368 USDC, credited to the attacker EOA in the exploit transaction balance diff. The exploit also forcibly unwound leveraged Moonwell positions across four victim strategy wallets and triggered reward claims that altered the victims' OP and xWELL balances.
The balance diff captures the key impact facts:
{
"holder": "0x36491840ebcf040413003df9fb65b6bc9a181f52",
"token": "0x0b2c639c533813f4aa9d7837caf62653d097ff85",
"before": "0",
"after": "318987572368",
"delta": "318987572368"
}
It also shows reward side effects such as OP and xWELL being credited to victim wallets during the forced redemption path. The incident therefore combined direct USDC theft with unauthorized strategy unwinding and reward state mutation.
0xd12016b25d7aef681ade3dc3c9d1a1cc12f35b2c99953ff0e0ee23a59454c4fe on Optimism.0xd9b45e2c389b6ad55dd3631abc1de6f2d2229847, especially smartSupply, smartRedeem, and executeOperation.iter_1/address/10/.../summary.json artifacts.executeOperation, approvals to the helper, and helper-driven transferFrom drains.mUSDC depletion.0xf9524bfa18c19c3e605fbfe8dfd05c6e967574aa, specifically the public claimReward(address holder) flow used during forced redemption.