All incidents

Usual Router Privilege Abuse

Share
May 27, 2025 18:31 UTCAttackLoss: 42,973.67 USD0Pending manual check1 exploit txWindow: Atomic
Estimated Impact
42,973.67 USD0
Label
Attack
Exploit Tx
1
Addresses
2
Attack Window
Atomic
May 27, 2025 18:31 UTC → May 27, 2025 18:31 UTC

Exploit Transactions

TX 1Ethereum
0x585d8be6a0b07ca2f94cfa1d7542f1a62b0d3af5fab7823cbcf69fb243f271f8
May 27, 2025 18:31 UTCExplorer

Victim Addresses

0xe033cb1bb400c0983fa60ce62f8ecdf6a16fce09Ethereum
0x1d08e7adc263cfc70b1babe6dc5bb339c16eec52Ethereum

Loss Breakdown

42,973.67USD0

Similar Incidents

Root Cause Analysis

Usual Router Privilege Abuse

1. Incident Overview TL;DR

An unprivileged attacker used Usual's public VaultRouter.deposit entrypoint to spend the router's privileged USD0++ -> USD0 unwrap capacity with attacker-controlled ParaSwap calldata. The attacker routed the unwrapped USD0 into a self-created thin USD0/sUSDS Uniswap V3 pool, recovered almost all of that USD0 by burning the LP position they owned, sold the recovered USD0 into the imbalanced USD0/USD0++ Curve pool, repaid the USD0++ flash loan, and exited with net profit.

The root cause is not the Curve swap itself. The defect is that the public router combines a privileged unwrapWithCap path with a fully attacker-controlled aggregator call while trusting only attacker-specified minimum output bounds. That design lets any public caller externalize the router's privileged redemption capability into economically recoverable USD0.

2. Key Background

Usd0PP.unwrapWithCap is privileged. In Usd0PP.sol, the function requires USD0PP_CAPPED_UNWRAP_ROLE, checks that the caller has a nonzero cap, enforces amount <= unwrapCaps[msg.sender], then burns USD0++ and transfers USD0 to the caller.

VaultRouter legitimately holds that privilege so users can reach the wrapped vault through a public router flow. In VaultRouter.sol, deposit(...) accepts USD0++ or sUSDS, converts inputs to vault tokens, then deposits into WrappedDollarVault.

The monetization venue already existed before the exploit. The seed pre-state shows the USD0/USD0++ Curve pool at 0x1d08...ec52 was imbalanced toward USD0++, making it profitable to sell recovered USD0 into that pool after the router abuse.

3. Vulnerability Analysis & Root Cause Summary

The vulnerable component is the public VaultRouter conversion flow. When tokenIn == USD0PP, the router first transfers the caller's USD0++ to itself, then calls MINTER_USD0PP.unwrapWithCap(amountUSD0ppIn), consuming the router's own capped unwrap privilege. After obtaining USD0, the router performs a low-level call into ParaSwap using attacker-provided calldata and checks only that the exact input amount left the router and that the attacker-chosen minimum output was met.

That means the protocol, not the caller, bears the pricing and routing risk of a privileged conversion. The attacker exploited this by creating a private USD0/sUSDS pool containing only 10 sUSDS, routing the router's entire 1899838465685386939269479 USD0 through that attacker-owned venue, and accepting only 9 sUSDS as router output. Because the attacker also owned the Uniswap V3 liquidity position, the USD0 sent into the pool was later recovered almost in full by withdrawing liquidity. The recovered USD0 was then monetized against the pre-existing Curve imbalance to obtain more USD0++ than the borrowed principal. The protocol invariant that a public router call must not convert privileged redemption rights into attacker-recoverable value was therefore violated.

The decisive breakpoint is the combination of these two internal router steps:

IERC20(USD0PP).safeTransferFrom(_msgSender(), address(this), amountUSD0ppIn);
MINTER_USD0PP.unwrapWithCap(amountUSD0ppIn);
return _executeParaswap(augustus, swapData, USD0, SUSDS, amountUSD0, minTokensToReceive);

and:

address tokenTransferProxy = augustus.getTokenTransferProxy();
assetToSwapFrom.approve(tokenTransferProxy, amountToSwap);
(bool success,) = address(augustus).call(data);
if (assetToSwapFrom.balanceOf(address(this)) != balanceBeforeAssetFrom - amountToSwap) {
    revert IncorrectAmountSent();
}

4. Detailed Root Cause Analysis

The exploit required three conditions that were all true in the seed state at block 22575930: the router had USD0PP_CAPPED_UNWRAP_ROLE and a live unwrap cap, the public router accepted arbitrary ParaSwap calldata, and the USD0/USD0++ Curve pool was already skewed toward USD0++.

The on-chain code path is direct. VaultRouter.deposit(...) calls _convertToTokens(...). For USD0++ input that flows into _convertUSD0ppToTokens(...), which unwraps with the router's own role, then forwards the freshly obtained USD0 into _executeParaswap(...). _executeParaswap(...) validates the Augustus address but does not constrain venue selection, receiver semantics embedded inside calldata, route composition, or any protocol-owned price bound.

The privileged token code independently confirms the trust boundary failure. In Usd0PP.sol, unwrapWithCap is gated by USD0PP_CAPPED_UNWRAP_ROLE and decrements the caller's dedicated cap. The public caller never held that role, but the router did, so the public router call turned router-held privilege into public economic access.

The seed trace confirms that exact sequence. In the trace for transaction 0x585d8be6a0b07ca2f94cfa1d7542f1a62b0d3af5fab7823cbcf69fb243f271f8, the attacker helper at 0xfb45...d1f calls VaultRouter, which then calls Usd0PP.unwrapWithCap(1899838465685386939269479). The trace emits:

0x35D8949372D46B7a3D5A56006AE77B215fc69bC0::unwrapWithCap(1899838465685386939269479)
emit CappedUnwrap(param0: 0xE033cb1bB400C0983fA60ce62f8eCDF6A16fcE09, param1: 1899838465685386939269479, param2: 0)
emit Deposit(sender: 0xE033cb1bB400C0983fA60ce62f8eCDF6A16fcE09, owner: 0xfb45BcD7239774cdBC5018fD47faF1a2fc219D1F, assets: 5, shares: 5)

That event sequence shows the router itself consumed the capped unwrap privilege and still completed the wrapped-vault deposit leg, yielding only 5 vault shares after the attacker-controlled swap path returned almost no sUSDS.

After the router finished, the attacker withdrew from the self-owned Uniswap V3 position and recovered 1899838465685386939269477 USD0, almost the full unwrapped amount. The next monetization step is also visible in the trace:

0x1d08E7adC263CfC70b1BaBe6dC5Bb339c16Eec52::exchange(0, 1, 1899838465685386939269477, 0, 0xfb45...)
0x1d08E7adC263CfC70b1BaBe6dC5Bb339c16Eec52::exchange(1, 0, 43847725777335611631336, 0, 0xfb45...)

This is the end-to-end exploit realization: privileged unwrap through the router, attacker recovery of principal from a self-owned venue, sale into an imbalanced public pool, flash-loan repayment, and surplus extraction.

5. Adversary Flow Analysis

The adversary cluster consisted of the sender EOA 0x2ae2f691642bb18cd8deb13a378a0f95a9fee933, the orchestrator 0xf195b8800b729aee5e57851dd4330fcbb69f07ea, and helper 0xfb45bcd7239774cdbc5018fd47faf1a2fc219d1f. The seed transaction was public, single-block, and required no privileged keys or private orderflow assumptions.

Stage 1 was funding and venue setup. The attacker flash-loaned 1899838465685386939269479 USD0++, created a new USD0/sUSDS Uniswap V3 pool, and minted the LP NFT to the helper. The trace shows createAndInitializePoolIfNecessary(...) and mint(...) with only 10 sUSDS supplied on the attacker side.

Stage 2 was the router abuse. The helper called VaultRouter.deposit with USD0++ input and attacker-crafted ParaSwap calldata. Because the router trusted the calldata and attacker-selected minimum output, the router spent its entire privileged unwrap amount while receiving only enough sUSDS to mint 5 wrapped-vault shares to the helper.

Stage 3 was recovery and monetization. The attacker burned the self-owned LP position to recover nearly all of the routed USD0, exchanged that USD0 into USD0++ on Curve at favorable pool pricing, repaid the flash loan principal, then sold the residual USD0++ back into USD0 and onward into USDC and WETH. balance_diff.json measures the sender's native balance rising from 0.15 ETH to 16.037105773747314980 ETH, a delta of 15.887105773747314980 ETH net of gas.

6. Impact & Losses

The direct economic loss identified in the analysis is 42973674683230843641696 raw units of USD0 (decimal=18). That amount represents the exploitable surplus extracted after the router privilege abuse was converted into a profitable Curve monetization path.

The affected components were the public VaultRouter at 0xe033cb1bb400c0983fa60ce62f8ecdf6a16fce09 and the USD0/USD0++ Curve pool at 0x1d08e7adc263cfc70b1babe6dc5bb339c16eec52. The practical impact was broader than a bad swap quote: a public caller could spend protocol-held redemption privilege and redirect the value into an attacker-recoverable venue. That is an attack-class root cause, not benign MEV.

7. References

  1. Seed transaction metadata: metadata.json
  2. Seed transaction trace: trace.cast.log
  3. Seed transaction balance diff: balance_diff.json
  4. VaultRouter source: VaultRouter.sol
  5. Usd0PP source: Usd0PP.sol
  6. Curve pool metadata: metadata.json
  7. Curve pool source: CurveStableSwapNG.vy