All incidents

The Standard Self-Swap Bad Debt

Share
Nov 06, 2023 21:31 UTCAttackLoss: 291,450 EUROsPending manual check1 exploit txWindow: Atomic
Estimated Impact
291,450 EUROs
Label
Attack
Exploit Tx
1
Addresses
2
Attack Window
Atomic
Nov 06, 2023 21:31 UTC → Nov 06, 2023 21:31 UTC

Exploit Transactions

TX 1Arbitrum
0x51293c1155a1d33d8fc9389721362044c3a67e0ac732b3a6ec7661d47b03df9f
Nov 06, 2023 21:31 UTCExplorer

Victim Addresses

0xba169cceccf7ac51da223e04654cf16ef41a68ccArbitrum
0x2e9f9cc46679dbb5d94a1397bd922ca5f6da99cdArbitrum

Loss Breakdown

291,450EUROs

Similar Incidents

Root Cause Analysis

The Standard Self-Swap Bad Debt

1. Incident Overview TL;DR

On Arbitrum block 147817766, transaction 0x51293c1155a1d33d8fc9389721362044c3a67e0ac732b3a6ec7661d47b03df9f let an unprivileged adversary turn sound WBTC collateral inside a freshly minted The Standard vault into dust PAXG, leaving the vault with unbacked EUROs debt. The originating EOA 0x09ed480feaf4cbc363481717e04e2c394ab326b4 called helper contract 0xb589d4a36ef8766d44c9785131413a049d51dbc0, borrowed 10.00000010 WBTC from a public Uniswap V3 flash pool, minted a fresh vault, minted 290000 EUROs against 10 WBTC collateral, created a new WBTC/PAXG Uniswap V3 pool, and then forced the vault to swap its collateral through that attacker-created pool.

The root cause is a vault-side logic flaw in SmartVaultV2.swap(): the function routes owner-selected collateral swaps with amountOutMinimum = 0 and never checks whether the vault remains solvent after the swap. Because Uniswap V3 pool creation is permissionless, the attacker could create the exact WBTC/PAXG fee-3000 pool that the vault’s hardcoded route expected, seed it with dust liquidity at an arbitrary price, and convert 995000000 raw WBTC units from the vault into only 99999998982 raw PAXG units. The attacker then sold part of the freshly minted EUROs for USDC, bought back the WBTC needed for flash-loan repayment, and finished the transaction with 280000 EUROs and 8512.226242 USDC while the protocol was left with a vault carrying 291450 EUROs of debt against negligible collateral.

2. Key Background

The Standard vault model allows a vault owner to deposit accepted collateral, mint EUROs against that collateral, and later manage the collateral through owner-only actions such as removeCollateral, removeAsset, and swap. The seed trace shows the relevant public protocol components in this incident:

  • SmartVaultManager at 0xba169cceccf7ac51da223e04654cf16ef41a68cc
  • Freshly deployed vault at 0x2e9f9cc46679dbb5d94a1397bd922ca5f6da99cd
  • EUROs token at 0x643b34980e635719c15a2d4ce69571a258f940e9
  • Uniswap V3 router at 0xE592427A0AEce92De3Edee1F18E0157C05861564

The critical background detail is that Uniswap V3 pools are permissionless, while SmartVaultV2.swap() does not discover a trusted venue dynamically. It resolves token symbols through the token manager, hardcodes fee tier 3000, and sends the swap to the configured router. That means safety depends on the vault contract enforcing its own slippage and solvency invariants rather than trusting any pool that matches (tokenIn, tokenOut, fee).

The verified manager code also shows why a fresh vault is easy for any attacker to obtain. SmartVaultManagerV52::mint() is a public function that mints a vault NFT to the caller, deploys a new vault, and grants the vault mint/burn rights on EUROs:

function mint() external returns (address vault, uint256 tokenId) {
    tokenId = lastToken + 1;
    _safeMint(msg.sender, tokenId);
    lastToken = tokenId;
    vault = ISmartVaultDeployer(smartVaultDeployer).deploy(address(this), msg.sender, euros);
    smartVaultIndex.addVaultAddress(tokenId, payable(vault));
    IEUROs(euros).grantRole(IEUROs(euros).MINTER_ROLE(), vault);
    IEUROs(euros).grantRole(IEUROs(euros).BURNER_ROLE(), vault);
}

Origin: verified SmartVaultManagerV52 source collected for the manager artifact.

3. Vulnerability Analysis & Root Cause Summary

This is an ATTACK-class ACT incident, not a privileged compromise. The broken invariant is straightforward: after any owner action, a vault’s debt should still satisfy minted <= maxMintable(). The vault enforces that invariant for explicit collateral removal, but not for collateral swaps.

The verified SmartVaultV2 source shows the asymmetry clearly. removeCollateral() and removeAsset() both gate withdrawals through canRemoveCollateral(), which prices the removal and ensures the remaining collateral still covers minted debt. mint() likewise enforces fullyCollateralised(). In contrast, swap() computes a fee, resolves the token addresses, builds an ExactInputSingleParams struct with amountOutMinimum: 0, and executes the swap without any post-condition that checks minted, maxMintable(), or undercollateralised().

That behavior is the code-level breakpoint. Once the attacker has minted EUROs against legitimate WBTC collateral, the vault owner can intentionally destroy collateral value by routing the swap through a pool with manipulated pricing and vanishing liquidity. Because the function accepts any accepted-token pair and uses the router’s normal pool lookup, an attacker can create the matching pool first and ensure the vault trades into the attacker’s venue.

The relevant source excerpt is below:

function removeCollateral(bytes32 _symbol, uint256 _amount, address _to) external onlyOwner {
    ITokenManager.Token memory token = getTokenManager().getToken(_symbol);
    require(canRemoveCollateral(token, _amount), UNDER_COLL);
    IERC20(token.addr).safeTransfer(_to, _amount);
}

function removeAsset(address _tokenAddr, uint256 _amount, address _to) external onlyOwner {
    ITokenManager.Token memory token = getTokenManager().getTokenIfExists(_tokenAddr);
    if (token.addr == _tokenAddr) require(canRemoveCollateral(token, _amount), UNDER_COLL);
    IERC20(_tokenAddr).safeTransfer(_to, _amount);
}

function swap(bytes32 _inToken, bytes32 _outToken, uint256 _amount) external onlyOwner {
    uint256 swapFee = _amount * ISmartVaultManagerV2(manager).swapFeeRate() / ISmartVaultManager(manager).HUNDRED_PC();
    address inToken = getSwapAddressFor(_inToken);
    ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({
        tokenIn: inToken,
        tokenOut: getSwapAddressFor(_outToken),
        fee: 3000,
        recipient: address(this),
        deadline: block.timestamp,
        amountIn: _amount - swapFee,
        amountOutMinimum: 0,
        sqrtPriceLimitX96: 0
    });
    inToken == ISmartVaultManagerV2(manager).weth() ?
        executeNativeSwapAndFee(params, swapFee) :
        executeERC20SwapAndFee(params, swapFee);
}

Origin: verified SmartVaultV2 source from Sourcify for vault 0x2e9f9cc46679dbb5d94a1397bd922ca5f6da99cd.

The security principles violated are:

  • Collateral-preserving operations must enforce the same solvency invariant as explicit collateral withdrawals.
  • DEX swaps of collateral must enforce meaningful output bounds, not amountOutMinimum = 0.
  • Permissionless AMM pools cannot be treated as trusted price-discovery sources without independent validation.

4. Detailed Root Cause Analysis

The ACT opportunity is defined from the Arbitrum public pre-state immediately before the seed transaction in block 147817766. In that pre-state, the attacker did not need any privileged role, private key, or hidden off-chain artifact. The attacker only needed public protocol entrypoints plus temporary capital, which came from a public Uniswap V3 flash loan.

The seed trace shows the full exploit sequence:

  1. The EOA 0x09ed480feaf4cbc363481717e04e2c394ab326b4 called helper contract 0xb589d4a36ef8766d44c9785131413a049d51dbc0.
  2. The helper borrowed 1000000010 raw WBTC units from flash pool 0x2f5e87c9312fa29aed5c179e456625d79015299c.
  3. The helper called SmartVaultManagerV52::mint() and received vault 0x2e9f9cc46679dbb5d94a1397bd922ca5f6da99cd.
  4. The helper deposited 1000000000 raw WBTC units and minted 290000000000000000000000 raw EUROs units, while the protocol received a mint fee of 1450000000000000000000 raw EUROs units.
  5. The helper created a new WBTC/PAXG fee-3000 Uniswap V3 pool at 0x29046f8f9e7623a6a21cc8c3cc2a2121ae855b8d, initialized it at an attacker-chosen price corresponding to a 1e10 raw reserve ratio between PAXG wei and WBTC sats, and seeded it with only 10 raw WBTC units and 100000000000 raw PAXG units.
  6. The helper invoked vault.swap(WBTC, PAXG, 1000000000), causing the vault to send 5000000 raw WBTC units to the protocol as a swap fee and route the remaining 995000000 raw WBTC units through the attacker-created pool.
  7. The vault received only 99999998982 raw PAXG units in return, collapsing the economic value of the collateral while leaving the minted EUROs debt outstanding.
  8. The helper sold 10000000000000000000000 raw EUROs units for 10435742169 raw USDC units, bought 5500014 raw WBTC units to cover the flash-loan shortfall, repaid 1000500011 raw WBTC units to the flash pool, and transferred the remaining USDC and EUROs profit to the originating EOA.

The most important trace fragment is the malicious swap itself:

0x2E9f9Cc46679DBb5D94a1397Bd922cA5F6dA99Cd::swap(WBTC, PAXG, 1000000000)
  ...
  0xE592427A0AEce92De3Edee1F18E0157C05861564::exactInputSingle(
    tokenIn=WBTC,
    tokenOut=PAXG,
    fee=3000,
    recipient=0x2E9f9Cc46679DBb5D94a1397Bd922cA5F6dA99Cd,
    amountIn=995000000,
    amountOutMinimum=0
  )
    0x29046f8f9E7623a6A21Cc8c3CC2a2121aE855b8d::swap(...)
      PAXG::transfer(vault, 99999998982)
      WBTC::transferFrom(vault, pool, 995000000)

Origin: seed transaction trace.

This is the decisive failure mode. Before the swap, the collateral was real WBTC. After the swap, the vault still held an accepted token, but only in a quantity whose EUR value was dust relative to the outstanding debt. The analysis artifact reports the post-attack vault state as minted = 291450000000000000000000, maxMintable = 166525816665503, and totalCollateralValue = 183178398332054, which is far below one EURO in 1e18 scaling and therefore consistent with effective bad debt. The manager’s own liquidation path confirms the intended invariant boundary by requiring vault.undercollateralised() before liquidating a vault.

The exploit conditions in root_cause.json are supported by the same evidence:

  • The attacker can mint or control a fresh vault NFT: shown by the public mint() call.
  • The pair must be supported by the token manager: the trace shows both WBTC and PAXG returned from getAcceptedTokens().
  • A permissionless router path must exist for the fixed fee tier: the attacker created the exact Uniswap V3 pool the router used.
  • Temporary capital is needed for the initial deposit and fee: satisfied by the public flash loan.

5. Adversary Flow Analysis

The adversary cluster consisted of:

  • EOA 0x09ed480feaf4cbc363481717e04e2c394ab326b4, which sent the seed transaction and received the final EUROs and USDC profit.
  • Helper contract 0xb589d4a36ef8766d44c9785131413a049d51dbc0, which executed the flash loan, vault mint, pool creation, collateral swap, repayment, and final transfers.

The attacker lifecycle has three concrete stages.

Flash Funding

The helper borrowed 1000000010 raw WBTC units from the public pool 0x2f5e87c9312fa29aed5c179e456625d79015299c. The balance diff confirms the pool ended with +500001 raw WBTC units, which is the flash-loan fee.

Vault Minting and Malicious Pool Setup

The helper minted vault token ID 165, received vault 0x2e9f9cc46679dbb5d94a1397bd922ca5f6da99cd, deposited 10 WBTC, and minted 290000 EUROs. It then created pool 0x29046f8f9e7623a6a21cc8c3cc2a2121ae855b8d with an attacker-chosen initialization price corresponding to a 1e10 raw reserve ratio and dust liquidity:

createAndInitializePoolIfNecessary(WBTC, PAXG, 3000, 7922816251426433759354395033600000)
mint(
  token0=WBTC,
  token1=PAXG,
  fee=3000,
  amount0Desired=10,
  amount1Desired=100000000000
)

Origin: seed transaction trace.

Collateral Destruction and Profit Realization

After the vault swap, the helper monetized the bad debt:

  • Sold 10000 EUROs for 10435742169 raw USDC units in pool 0xc9aa2feb84f0134a38d5d1c56b1e787191327cb0.
  • Bought 5500014 raw WBTC units for 1923515927 raw USDC units in pool 0xac70bd92f89e6739b3a08db9b6081a923912f73d.
  • Repaid the flash loan plus fee.
  • Sent 8512226242 raw USDC units and 280000000000000000000000 raw EUROs units to the originating EOA.

The attack is ACT because every step uses permissionless public contracts and public data. The helper contract is not a source of privilege; it is only an attacker-deployed convenience wrapper that any unprivileged actor could replace with an equivalent contract.

6. Impact & Losses

The protocol impact is a bad-debt vault: the attacker extracted EUROs against real WBTC collateral and then intentionally destroyed the collateral value before repayment. The measurable protocol loss recorded in the analysis artifact is:

  • EUROs: 291450000000000000000000 raw units (291450 EUROs, 18 decimals)

The attacker’s directly realized profit in the seed transaction was:

  • 280000000000000000000000 raw EUROs units to the originating EOA
  • 8512226242 raw USDC units (8512.226242 USDC) to the originating EOA
  • minus 1496895400000000 wei of native gas spend

The vault’s collateral was not merely reduced; it was transformed from 10 WBTC into effectively worthless PAXG-sized dust under the protocol’s own collateral valuation, leaving the EUROs supply underbacked. This is why the correct loss accounting is the full bad debt, not just the attacker’s immediately monetized USDC.

7. References

  1. Seed transaction: 0x51293c1155a1d33d8fc9389721362044c3a67e0ac732b3a6ec7661d47b03df9f
  2. Seed transaction metadata: /workspace/session/artifacts/collector/seed/42161/0x51293c1155a1d33d8fc9389721362044c3a67e0ac732b3a6ec7661d47b03df9f/metadata.json
  3. Seed transaction trace: /workspace/session/artifacts/collector/seed/42161/0x51293c1155a1d33d8fc9389721362044c3a67e0ac732b3a6ec7661d47b03df9f/trace.cast.log
  4. Seed transaction balance diff: /workspace/session/artifacts/collector/seed/42161/0x51293c1155a1d33d8fc9389721362044c3a67e0ac732b3a6ec7661d47b03df9f/balance_diff.json
  5. Collected SmartVaultManager source: /workspace/session/artifacts/collector/seed/42161/0x7c1acd1a7ba8c5f9f511bc0274b71a12c4be543d/src/SmartVaultManagerV52.sol
  6. Verified SmartVaultV2 source (Sourcify): https://repo.sourcify.dev/contracts/full_match/42161/0x2e9f9cc46679dbb5d94a1397bd922ca5f6da99cd/sources/contracts/SmartVaultV2.sol
  7. Validator root-cause challenge result: /workspace/session/artifacts/validator/root_cause_challenge_result.json