Public SwapGuard envelope enabled arbitrary transferFrom drain of CoW Settlement DAI allowance
Exploit Transactions
0x90b468608fbcc7faef46502b198471311baca3baab49242a4a85b73d4924379bVictim Addresses
0x9008d19f58aabd9ed0d60971565aa8510560ab41Ethereum0xcd07a7695e3372acd2b2077557de93e667b92bd8EthereumLoss Breakdown
Similar Incidents
V3Utils Arbitrary Call Drain
37%Dexible selfSwap allowance drain
37%LiFi GasZipFacet / LibSwap arbitrary USDT transferFrom
35%WBTC Drain via Insecure Router transferFrom Path
34%Euler DAI Reserve Donation
34%GPv2Settlement allowance leak lets router drain WETH and USDC
33%Root Cause Analysis
Public SwapGuard envelope enabled arbitrary transferFrom drain of CoW Settlement DAI allowance
1. Incident Overview TL;DR
On Ethereum mainnet block 16574049, transaction 0x90b468608fbcc7faef46502b198471311baca3baab49242a4a85b73d4924379b drained 114824.890807160711319588 DAI from CoW Settlement (0x9008d19f58aabd9ed0d60971565aa8510560ab41). The attacker EOA 0xc0e82c1ed4786f8b7f806d1b8a6335ec485266ff used SwapGuard (0xcd07a7695e3372acd2b2077557de93e667b92bd8) to execute a single DAI.transferFrom(...) that moved the full victim balance directly to the attacker.
The root cause is a broken accounting invariant inside SwapGuard.envelope. The function lets the caller choose both the arbitrary low-level call bundle and the vault address whose balances are monitored. Because the loss check only compares tokens[i].balanceOf(vault) before and after execution, a caller can drain a completely different address that has approved SwapGuard, while presenting an unrelated vault whose balance never changes.
2. Key Background
SwapGuard is designed as a public executor that performs arbitrary interactions and then enforces a post-condition on token balances. The relevant public entrypoint is envelope(Data[] interactions, address vault, IERC20[] tokens, uint256[] tokenPrices, int256[] balanceChanges, uint256 allowedLoss). Its safety model assumes that the supplied vault is the asset source that should be protected.
That assumption is unsafe when the executor also performs unrestricted low-level calls. In ERC20, transferFrom(from, to, amount) spends the allowance that from previously granted to msg.sender. If some third party has already approved SwapGuard, then any successful call executed by SwapGuard can move that third party's tokens without ever touching the caller-supplied vault.
The relevant pre-state at block 16574048 was:
- CoW Settlement held
114824890807160711319588raw DAI units (114824.890807160711319588 DAI). - CoW Settlement had already approved SwapGuard for
uint256.maxDAI allowance. - The observed monitored vault
0xeb8f71a5669a55cf90e384c77e74c4bdf9ae7754had0DAI before execution and0DAI after execution.
Those three conditions were sufficient for a permissionless exploit. No privileged key, privileged contract role, or attacker-specific artifact was required.
3. Vulnerability Analysis & Root Cause Summary
This incident is an ATTACK-category ACT exploit, not a speculative loss hypothesis. SwapGuard exposes a public function that accepts attacker-controlled interaction calldata and an attacker-controlled accounting scope. The contract then checks losses against the chosen vault instead of the actual address whose tokens are debited by the executed calls. The exploit transaction used that mismatch to call DAI.transferFrom(CoW Settlement, attacker, full_balance) through SwapGuard while monitoring a different address with a flat DAI balance. As a result, the victim lost its full approved DAI balance even though SwapGuard computed zero loss for the only tracked token. The broken invariant is: any token loss caused by envelope must be accounted against the true token source whose allowance is consumed, not against a caller-chosen proxy address. Because envelope is public and the required allowance was already on-chain, any unprivileged actor could realize the same exploit predicate from public state alone.
4. Detailed Root Cause Analysis
The verified SwapGuard source shows the exact invariant break:
function envelope(
Data[] calldata interactions,
address vault,
IERC20[] calldata tokens,
uint256[] calldata tokenPrices,
int256[] calldata balanceChanges,
uint256 allowedLoss
) public payable {
uint256[] memory balancesBeforeInteractions = new uint256[](tokens.length);
for (uint256 i = 0; i < tokens.length; i++) {
balancesBeforeInteractions[i] = tokens[i].balanceOf(vault);
}
for (uint256 i = 0; i < interactions.length; i++) {
Data memory interaction = interactions[i];
(bool success, bytes memory returnData) =
interaction.target.call{value: interaction.value}(interaction.callData);
if (!success) revert BadInteractionResponse(returnData);
}
for (uint256 i = 0; i < tokens.length; i++) {
uint256 balanceAfterInteraction = tokens[i].balanceOf(vault);
int256 actualBalanceChange =
balanceAfterInteraction.toInt256() - balancesBeforeInteractions[i].toInt256();
if (actualBalanceChange < balanceChanges[i]) {
totalLoss += (balanceChanges[i] - actualBalanceChange).toUint256() * tokenPrices[i];
}
if (totalLoss > allowedLoss) revert LostMoreThanAllowed(totalLoss, allowedLoss);
}
}
Two facts matter in that implementation. First, the caller fully controls vault. Second, the interaction loop performs arbitrary low-level calls without binding the calldata to the monitored vault. That means the debit source inside transferFrom and the address used for accounting can diverge.
The decoded execution trace for the exploit transaction confirms that this is exactly what happened:
SwapGuard::envelope(
[Data({ target: DAI, callData: transferFrom(CoW Settlement, attacker, 114824890807160711319588) })],
0xEB8f71A5669A55Cf90e384C77e74c4bdf9aE7754,
[DAI],
[0],
[0],
type(uint256).max
)
Dai::balanceOf(0xEB8f71A5669A55Cf90e384C77e74c4bdf9aE7754) -> 0
Dai::transferFrom(0x9008D19f58AAbD9eD0D60971565AA8510560ab41, attacker, 114824890807160711319588)
Dai::balanceOf(0xEB8f71A5669A55Cf90e384C77e74c4bdf9aE7754) -> 0
The trace also shows the victim pre-state immediately before the exploit:
Dai::balanceOf(0x9008D19f58AAbD9eD0D60971565AA8510560ab41) -> 114824890807160711319588
Dai::allowance(0x9008D19f58AAbD9eD0D60971565AA8510560ab41, SwapGuard) -> 115792089237316195423570985008687907853269984665640564039457584007913129639935
Because the monitored vault started at 0 DAI and ended at 0 DAI, SwapGuard computed actualBalanceChange = 0. The supplied expected change was also 0, so actualBalanceChange < expectedBalanceChange never became true and totalLoss remained 0. Meanwhile, the real token source was CoW Settlement, whose DAI balance was fully drained.
The transaction receipt independently confirms the value movement with a successful DAI Transfer log from CoW Settlement to the attacker for the exact same amount. This establishes a deterministic, code-backed exploit chain:
- Observe a victim with live ERC20 allowance to SwapGuard.
- Choose a vault whose tracked balance will not decrease.
- Encode
transferFrom(victim, attacker, victimBalanceBefore)as an interaction executed by SwapGuard. - Let SwapGuard verify only the chosen vault's post-state and return successfully.
The exploit does not depend on the observed helper contract address. The vulnerability exists in SwapGuard's public envelope semantics and can be reproduced by any unprivileged caller with fresh addresses.
5. Adversary Flow Analysis
The adversary flow is a single-transaction direct allowance drain.
- Before block
16574049, the attacker observes that CoW Settlement holds114824.890807160711319588 DAIand that SwapGuard already has unlimited DAI allowance from CoW Settlement. - The attacker prepares one interaction targeting the DAI token contract
0x6b175474e89094c44da98b954eedeac495271d0fwith calldata equivalent totransferFrom(CoW Settlement, attacker, victimBalanceBefore). - The attacker selects
0xeb8f71a5669a55cf90e384c77e74c4bdf9ae7754as the monitoredvaultbecause its DAI balance is zero and stays zero, making the vault-scoped accounting check inert. - Transaction
0x90b468608fbcc7faef46502b198471311baca3baab49242a4a85b73d4924379bexecutes the exploit. The sender is0xc0e82c1ed4786f8b7f806d1b8a6335ec485266ff, the transaction succeeds, and the receipt emits the DAITransferto the same EOA. - The attacker ends the transaction with the stolen DAI and pays
0.001510649805383131 ETHin gas. No additional settlement step is needed because profit is realized immediately in the exploit transaction itself.
6. Impact & Losses
The measurable loss was the complete DAI balance that CoW Settlement had left spendable through SwapGuard at the fork block used by the exploit.
- Victim asset: DAI
- Raw amount drained:
114824890807160711319588 - Human-readable amount:
114824.890807160711319588 DAI - Loss scope: full pre-state DAI balance of CoW Settlement that was approved to SwapGuard
- Profit path: direct transfer to attacker EOA
0xc0e82c1ed4786f8b7f806d1b8a6335ec485266ff
The exploit completed in one successful transaction and did not require follow-up transactions, liquidation windows, or privileged post-processing.
7. References
- Exploit transaction on Ethereum mainnet:
0x90b468608fbcc7faef46502b198471311baca3baab49242a4a85b73d4924379b - Exploit block:
16574049 - Victim loss address: CoW Settlement
0x9008d19f58aabd9ed0d60971565aa8510560ab41 - Vulnerable protocol contract: SwapGuard
0xcd07a7695e3372acd2b2077557de93e667b92bd8 - Token drained: DAI
0x6b175474e89094c44da98b954eedeac495271d0f - Verified code evidence: SwapGuard
envelopeimplementation captured from verified source - Execution evidence: decoded transaction trace showing
balanceOf(vault),transferFrom(victim, attacker, amount), and unchanged vault balance - Receipt evidence: successful DAI
Transferlog from CoW Settlement to the attacker EOA - Balance evidence: native balance delta showing attacker gas cost and pre-state metadata showing the victim allowance and balance