Reproduced Exploit
FlashLoopAdapter — caller self-attests as the Safe, then `swapRouter.call` runs the victim
FlashLoopAdapter is a Zodiac-style module for looping an Aave position through a flash loan and an aggregator. open / close both enter _start, which authenticates with:
Loss
114.096151469674448809 WETH left on the attacker after repaying a 1,400 WETH Morpho flash output.txt:7, outpu…
Chain
Ethereum
Category
access-control
Date
Oct 2026
Source
DeFiHackLabs
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2026-10-FlashLoopAdapter_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/FlashLoopAdapter_exp.sol.
Vulnerability classes: vuln/access-control/missing-auth · vuln/dependency/unsafe-external-call · vuln/input-validation/missing · vuln/logic/missing-check Reproduction: the PoC compiles and runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Online pass. There is no
anvil_state.jsonin this folder, so_shared/run_poc.shcannot replay it offline. The committed trace forks a live Ethereum RPC (createSelectFork("<rpc url>", 26098263)at output.txt:12) and passes.
Key info#
| Loss | 114.096151469674448809 WETH left on the attacker after repaying a 1,400 WETH Morpho flash output.txt:7, output.txt:839. Pre-gas. The test's assertApproxEqRel target is 114.092 WETH (1% tolerance) output.txt:840 |
| Vulnerable contract | FlashLoopAdapter 0x16bb8B912da187870C23eC6756bB3FAd061283d8 — verified, sources/FlashLoopAdapter_16bb8B/src_FlashLoopAdapter.sol |
| Victim Safe #1 | 0xCFeDF95a3653a128dFC2E4288758A1a1850D169f — Aave variable WETH debt 1,335.255802777509633370, weETH collateral withdrawn 1,306.482324969756972729 |
| Victim Safe #2 | 0xE3b23E47dF7cD85876aC6cB05BDb9d7cd5b28520 — plain weETH 6.426087021311600894 |
| Attacker EOA (historical) | 0x42c2633438609881c8fBAb82414eb9A0c45F9353 |
| Historical attack contract | 0xF09168963ac7b31917A02Aa82fA9Cd667F4B67ff |
| This PoC's attacker | AttackerModule at 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f output.txt:18 |
| Attack tx | 0x75328f916b1a0878724d364da5eb12b255160b894cb36c63ed5d718efc616fc4 (block 26,098,264) |
| Chain / block / date | Ethereum / fork parent 26,098,263, vm.roll 26,098,264, vm.warp 1,790,867,327 = 2026-10-01 15:08:47 UTC output.txt:12, output.txt:14, output.txt:16 |
| Compiler | v0.8.28+commit.7893614a, optimizer on, 200 runs (sources/FlashLoopAdapter_16bb8B/_meta.json). Transient _pending / _safe need Cancun |
| Bug class | open() treats msg.sender as the Safe because it asks msg.sender whether the adapter is an enabled module. _swap then calls a caller-supplied swapRouter with caller-supplied calldata. Pointing that call at a real Safe that enabled the adapter makes the Safe execute as itself. |
TL;DR#
FlashLoopAdapter is a Zodiac-style module for looping an Aave position through a flash loan and an aggregator. open / close both enter _start, which authenticates with:
if (!ISafe(msg.sender).isModuleEnabled(address(this))) revert ModuleNotEnabled();
_safe = msg.sender;
That is self-attestation. AttackerModule.isModuleEnabled returns true output.txt:103. The adapter then sets _safe to the attacker and, with providerAddr and pool also set to the attacker and flashAmount = 0, every other leg is a no-op against the attacker contract.
The one real effect is _swap. It approves tokenIn to swapRouter and does router.call(data) with both arguments taken from Params. The attacker sets swapRouter to a victim Safe and data to execTransactionFromModule(target, 0, inner, 0). Both victims had enabled this adapter, so the Safe singleton 0x41675C09…7461a accepts the call and runs the inner call as the Safe: Victim #2 weETH.transfer of 6.426087021311600894 output.txt:128, Victim #1 Pool.withdraw(weETH, type(uint256).max, attacker) of 1,306.482324969756972729 output.txt:219.
Victim #1's collateral was locked by 1,335.255802777509633370 variable WETH debt. Repaying that debt is permissionless and is done with a real Morpho Blue flash of 1,400 WETH before either open output.txt:53. ODOS then swaps 1,312.9 weETH for 1,449.351954247184082179 WETH output.txt:283, output.txt:707. After repaying 1,400 WETH the attacker holds 114.096151469674448809 WETH output.txt:838. No victim owner signed anything. [PASS] testExploit at output.txt:4.
Background#
The adapter's intended caller is a Safe that has enabled it. open flashes debt, swaps debt to collateral, supplies collateral on behalf of the Safe, and execTransactionFromModules a borrow plus a transfer back so the flash can be repaid. close does the reverse. swapRouter and swapCalldata exist so the Safe can pass an off-chain aggregator quote. minOut is checked against the adapter's tokenOut balance after the call, not against a decoded amount.
Both victims delegatecall the same Safe singleton 0x41675C099F32341bf84BFc5382aF534df5C7461a output.txt:113, output.txt:180. Module calls do not need owner signatures when the caller is an enabled module. The adapter really was that module; the missing check is which Safe it is willing to drive, and which address it is willing to call.
Aave v3 Pool is 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2. Variable debt WETH is 0xeA51d7853EEFb32b6ee06b1C12E6dcCA88Be0fFE. WETH 0xC02aaA39…6Cc2. weETH 0xCd5fE23C…b7ee. Morpho Blue 0xBBBBBbbB…FFCb (fee-free). ODOS router 0x6A000F20…1068. The PoC is test/FlashLoopAdapter_exp.sol. The weETH→WETH route is the historical calldata, replayed; its recipient is msg.sender, so the WETH arrives on this test's attacker.
The vulnerable code#
Verified source, sources/FlashLoopAdapter_16bb8B/src_FlashLoopAdapter.sol:
/// @notice msg.sender MUST be the Safe (which has this adapter enabled as module).
function open(Params calldata p) external { _start(p); }
function _start(Params calldata p) internal {
if (!ISafe(msg.sender).isModuleEnabled(address(this))) revert ModuleNotEnabled();
_safe = msg.sender;
_pending = keccak256(abi.encode(msg.sender, p));
// flash from p.providerAddr — attacker sets this to itself
if (p.provider == Provider.MORPHO) {
IMorpho(p.providerAddr).flashLoan(p.debt, p.flashAmount, data);
}
// ...
}
function _swap(address tokenIn, address tokenOut, address router, bytes memory data, uint256 minOut)
internal returns (uint256 got)
{
IERC20(tokenIn).approve(router, type(uint256).max);
(bool ok, bytes memory ret) = router.call(data); // router and data are caller-supplied
if (!ok) {
assembly { revert(add(ret, 0x20), mload(ret)) }
}
IERC20(tokenIn).approve(router, 0);
got = IERC20(tokenOut).balanceOf(address(this));
if (got < minOut) revert SwapMinOut(got, minOut);
}
_validate checks msg.sender == p.providerAddr and that the callback params match _pending. That only binds the flash callback to the address _start just called. It does not constrain swapRouter. With providerAddr = attacker and flashAmount = 0, the callback is the attacker's flashLoan re-entering onMorphoFlashLoan(0, data) output.txt:105. minOut = 0, so the balance check cannot fail. The unlimited approve is to the victim Safe; the Safe ignores it and executes the module call instead.
_run on Op.OPEN also supplys to p.pool and _moduleExecs borrow / repay against _safe. Those hit AttackerModule.supply and AttackerModule.execTransactionFromModule, both no-ops that return success output.txt:153, output.txt:155. The comment in source says the caller must be the Safe. The code never checks a registry of Safes that enabled this module.
Root cause#
- Authentication is delegated to the caller.
ISafe(msg.sender).isModuleEnabled(address(this))is true for any contract that implements the function to return true. A real Safe would answer honestly; an attacker answers yes._safe = msg.senderthen stores the lie for the rest of the operation. swapRouteris an arbitrary call target. No allowlist, no selector filter, nomsg.sender == routerrelationship.router.call(data)is an open dispatch from a contract that real Safes trust as a module.- The victims opted into that module.
execTransactionFromModulefrom0x16bb8B91…succeeds on both Safes (ExecutionFromModuleSuccessat output.txt:134). The drain is the module permission plus the two bugs, not a stolen owner key. - The rest of
Paramsis attacker-controlled and is used as the Aave pool and the flash source. Setting both to the attacker andflashAmountto 0 strips the function down to the singlecall.flashAmount = 0still enters_swapon the OPEN path (step 1 of_runruns before the borrow). - Collateral unlock is a separate, permissionless Aave action. Repaying
onBehalfOf = victimdoes not need the adapter. It is what makes Victim #1'swithdrawsucceed. The adapter bug is what makes the Safe be the one callingwithdraw.
Secondary analysis (@DefimonAlerts)#
FlashLoopAdapter.open authenticates by asking msg.sender whether the adapter is an enabled module, then _swap calls a caller-supplied router with caller-supplied calldata, so an attacker can make any Safe that enabled the module execute as itself.
Source: https://x.com/DefimonAlerts/status/2105765575766990932
Secondary analysis (@SlowMist_Team)#
Spoofable Safe-module auth: open()/close() trust ISafe(msg.sender).isModuleEnabled(address(this)); _swap() then performs an attacker-controlled router.call that invokes execTransactionFromModule on victim Safes that enabled the adapter.
Source: https://x.com/SlowMist_Team/status/2105855276536725599
Preconditions#
- The target Safe must have enabled this adapter (
0x16bb8B91…d8). A Safe that never enabled it would rejectexecTransactionFromModule. The PoC does not enable the module; it was already enabled at the fork block. - No owner signature, no
vm.prankof a victim, no private key. The onlyisModuleEnabledanswer in the trace is the attacker'strueoutput.txt:104. - Victim #1 must have a repayable WETH debt and weETH collateral. Debt read and repaid: 1,335.255802777509633370 output.txt:53. weETH sent to the attacker on withdraw: 1,306.482324969756972729 output.txt:219. aWeETH burned in the same withdraw: 1,306.482262583889567736 output.txt:203 (rebasing index, not a second transfer).
- A WETH flash large enough to repay that debt. Morpho lent 1,400 WETH with no fee output.txt:34. 1,400 − 1,335.255802777509633370 = 64.744197222490366630 WETH of the flash stayed with the attacker until the final repay.
- Fork timestamp.
vm.warp(1790867327)matches the attack block so the rebasing aWeETH balance matches the ODOS quote. The test says this explicitly.
Attack walkthrough (with on-chain numbers from the trace)#
| # | Action | Amount | Trace |
|---|---|---|---|
| 0 | Attacker WETH before | 0 | output.txt:6, output.txt:26 |
| 1 | Morpho flashLoan(WETH, 1400 ether) | +1,400 WETH | output.txt:34 |
| 2 | Pool.repay(WETH, debt, 2, Victim#1). Debt is the variable-debt token balance, not type(uint256).max (Aave reverts NoExplicitAmountToRepayOnBehalf) | 1,335.255802777509633370 WETH | output.txt:53, output.txt:83 |
| 3 | Victim #2 weETH balance | 6.426087021311600894 | output.txt:100 |
| 4 | adapter.open with swapRouter = Victim#2, flashAmount = 0, providerAddr = pool = attacker. isModuleEnabled returns true. Adapter calls Victim#2.execTransactionFromModule(weETH, 0, transfer(attacker, bal), 0) | 6.426087021311600894 weETH to the attacker. Victim #2 balance slot goes to 0 | output.txt:102, output.txt:103, output.txt:112, output.txt:128, output.txt:130 |
| 5 | Second open, swapRouter = Victim#1. Inner call is Pool.withdraw(weETH, type(uint256).max, attacker) | 1,306.482324969756972729 weETH to the attacker | output.txt:169, output.txt:179, output.txt:181, output.txt:219 |
| 6 | ODOS swapExactAmountIn, input exactly 1,312.9 weETH (dust remains: 6.426087021311600894 + 1,306.482324969756972729 = 1,312.908411991068573623) | WETH out 1,449.351954247184082179 | output.txt:283, output.txt:707 |
| 7 | Approve and repay Morpho 1,400 WETH | −1,400 WETH | output.txt:715, output.txt:721 |
| 8 | Logged profit | 114.096151469674448809 WETH | output.txt:838, output.txt:839 |
WETH identity
| Component | WETH |
|---|---|
| Morpho flash | +1,400 |
| Aave repay for Victim #1 | −1,335.255802777509633370 |
| ODOS proceeds | +1,449.351954247184082179 |
| Morpho repay | −1,400 |
| Net | 114.096151469674448809 |
Check: 1,400 − 1,335.255802777509633370 + 1,449.351954247184082179 − 1,400 = 114.096151469674448809. That is the equity of the two Safes after selling the seized weETH, not a fee-on-flash. Suite: 1 passed; 0 failed output.txt:851.
Diagrams#
Remediation#
- Store the Safe at enable time.
isModuleEnabledmust be called on a Safe the adapter already knows (enabled[msg.sender]set by the Safe in an opt-in that the adapter records), not onmsg.senderas an oracle about itself. A view on an arbitrary contract is not authentication. - Allowlist
swapRouter. The aggregator address should be immutable or owner-set. RejectswapRouterif it is a contract that can move third-party funds, in particular any Safe. Do notapprove(router, type(uint256).max)for a caller-supplied router; approve the quoted input amount to a known router, or pull tokens only after a successful swap callback from that router. - Constrain calldata. If an aggregator quote is required, check the selector and that the token path is
(tokenIn, tokenOut).execTransactionFromModulemust not be a reachable selector. minOutcannot be the only bound whentokenOut.balanceOf(adapter)is unchanged by a malicious call andminOutis 0. Measure the balance delta of the intended output token and require it.- Users who enabled this module should disable it. The permission is the blast radius. Disabling the module stops this dispatch even before an upgrade. Funds already sitting in a still-enabled Safe are exposed to anyone who can call
open.
How to reproduce#
No anvil_state.json. _shared/run_poc.sh 2026-10-FlashLoopAdapter_exp -vvvvv exits before Forge. The passing artifact is the online trace output.txt.
# from the registry root — offline harness refuses this folder (no anvil_state.json)
# _shared/run_poc.sh 2026-10-FlashLoopAdapter_exp -vvvvv
# online, matching the committed trace (Cancun: adapter transient storage)
cd 2026-10-FlashLoopAdapter_exp
forge test --match-contract FlashLoopAdapterExploit --evm-version cancun -vvvvv
- Chain / fork: Ethereum, block 26,098,263, then
warp(1790867327)androll(26098264). RPC in the test:https://eth.drpc.org. - Expected result (this is what output.txt already records,
finished in 554.77ms):
[PASS] testExploit()
Attacker Before exploit WETH Balance: 0.000000000000000000
Attacker profit (WETH): 114.096151469674448809
Attacker After exploit WETH Balance: 114.096151469674448809
Suite result: ok. 1 passed; 0 failed; 0 skipped
Reference: attack tx and fork RPC in the header of test/FlashLoopAdapter_exp.sol — 0x75328f916b1a0878724d364da5eb12b255160b894cb36c63ed5d718efc616fc4, RPC https://eth.drpc.org.
References#
-
https://x.com/DefimonAlerts/status/2105695635647144370 (@DefimonAlerts secondary analysis)
-
https://x.com/exvulsec/status/2105701761554190847 (@exvulsec secondary analysis)
-
https://x.com/DefimonAlerts/status/2105765575766990932 (@DefimonAlerts secondary analysis)
-
https://x.com/SlowMist_Team/status/2105855276536725599 (@SlowMist_Team secondary analysis)
-
https://x.com/exvulsec/status/2105898901589184712 (@exvulsec secondary analysis)
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2026-10-FlashLoopAdapter_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
FlashLoopAdapter_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "FlashLoopAdapter".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.