All incidents

Zunami UZD Revaluation Exploit

Share
Aug 13, 2023 22:34 UTCAttackLoss: 1,148.85 ETH, 1,265.46 USDTPending manual check1 exploit txWindow: Atomic
Estimated Impact
1,148.85 ETH, 1,265.46 USDT
Label
Attack
Exploit Tx
1
Addresses
2
Attack Window
Atomic
Aug 13, 2023 22:34 UTC → Aug 13, 2023 22:34 UTC

Exploit Transactions

TX 1Ethereum
0x0788ba222970c7c68a738b0e08fb197e669e61f9b226ceec4cab9b85abe8cceb
Aug 13, 2023 22:34 UTCExplorer

Victim Addresses

0xb40b6608b2743e691c9b54ddbdee7bf03cd79f1cEthereum
0x2ffcc661011bec72e1a9524e12060983e74d14ceEthereum

Loss Breakdown

1,148.85ETH
1,265.46USDT

Similar Incidents

Root Cause Analysis

Zunami UZD Revaluation Exploit

1. Incident Overview TL;DR

In Ethereum mainnet transaction 0x0788ba222970c7c68a738b0e08fb197e669e61f9b226ceec4cab9b85abe8cceb at block 17908950, the attacker used public flash liquidity to manipulate the market inputs consumed by Zunami UZD's price oracle, then refreshed UZD's cached price inside the same transaction. After the cache increase, the attacker moved UZD through rigid-address pools whose accounting stores value units but re-derives nominal balances from the current cached price on every conversion. That let the attacker withdraw value at the old cache, restore roughly the same nominal claim at the higher cache, and keep the face-value difference as profit.

The root cause is a broken conservation invariant between UZD's rigid and elastic accounting domains. cacheAssetPrice() is public and ratchets the cache upward, while rigid-to-elastic and elastic-to-rigid conversions both recompute nominal amounts from assetPriceCached() instead of preserving a stable per-position nominal basis. A caller who can temporarily raise the oracle can therefore revalue existing rigid balances and extract value without adding new collateral.

2. Key Background

UZD supports two accounting modes. Ordinary holders use elastic balances, while selected integration addresses use rigid balances recorded in value units. Transfers into or out of a rigid address trigger conversion helpers that translate between value units and nominal units.

The cached UZD price is not only readable but publicly refreshable. cacheAssetPrice() updates the cache to the current oracle price if and only if the current price is higher than the stored cache, so an attacker can lock in an intra-transaction upward manipulation.

Those two design choices interact badly. If the same rigid position is converted out before the cache update and converted back after the cache update, the protocol settles both legs with different cached prices while treating them as if they represented the same underlying claim.

3. Vulnerability Analysis & Root Cause Summary

This is an accounting attack, not a privileged-access issue. The vulnerable invariant is that moving value between rigid and elastic UZD domains should preserve the underlying nominal claim unless new collateral enters the system. Instead, UZD stores rigid balances as value amounts while _lockedNominal is adjusted using nominal amounts recomputed from the current cache. Because cacheAssetPrice() is public and only moves the cache upward, an attacker can first drain rigid-pool UZD at the stale cache and later send UZD back after raising the cache. The same nominal amount then corresponds to a much larger rigid face value on the return leg. The trace shows the pattern twice, once through 0x68934f60758243eafaf4d2cfed27bf8010bede3a and again through 0xfc636d819d1a98433402ec9dec633d864014f28c, which confirms the flaw is systemic to the conversion design rather than a single pool misconfiguration.

Victim code evidence from UZD:

function cacheAssetPrice() public virtual {
    _blockCached = block.number;
    uint256 currentAssetPrice = assetPrice();
    if (_assetPriceCached < currentAssetPrice) {
        _assetPriceCached = currentAssetPrice;
        emit CachedAssetPrice(_blockCached, _assetPriceCached);
    }
}
function _convertRigidToElasticBalancePartially(address owner, uint256 amount) internal {
    uint256 nominal = _convertToNominalWithCaching(amount, Math.Rounding.Up);
    _lockedNominal -= nominal;
    _increaseBalanceElastic(owner, nominal);
    emit ConvertedToElastic(owner, amount, nominal);
}

function _convertElasticToRigidBalancePartially(address owner, uint256 amount) internal {
    uint256 nominal = _convertToNominalWithCaching(amount, Math.Rounding.Up);
    _decreaseBalanceElastic(owner, nominal);
    _lockedNominal += nominal;
    _balancesRigid[owner] += amount;
    emit ConvertedToRigid(owner, amount, nominal);
}

4. Detailed Root Cause Analysis

Before the exploit transaction, UZD's cached price was approximately 0.912563583136402763, and the rigid pools still held UZD inventory. The attacker used public flash loans from the Uniswap V3 USDT pool 0x3416cf6c708da44db2624d63ea0aaef7113527c6 and the Balancer vault 0xBA12222222228d8Ba445958a75a0704d566BF2C8 to acquire the liquidity needed for both the oracle manipulation and the rigid-pool round trip.

First, the attacker pulled UZD out of rigid pools while the old cache was still in force. The trace records two ConvertedToElastic events, showing that rigid value was turned into elastic nominal balances using the stale cached price:

emit ConvertedToElastic(owner: 0xA21a2B59d80dC42D332F778Cbb9eA127100e5d75,
    value: 4082036167832726632955764,
    nominal: 4473152603573241929935452)
emit ConvertedToElastic(owner: 0xA21a2B59d80dC42D332F778Cbb9eA127100e5d75,
    value: 791280423737014253868058,
    nominal: 867096209359408477649725)

Next, the attacker manipulated the oracle inputs that feed UZD's price walk. The transaction moved 10000 WETH through the WETH/SDT route and 7000000000000 USDT through the USDT/WETH route, then called UZD.cacheAssetPrice(). Because the victim code only accepts higher cached prices, the manipulated state was ratcheted into settlement state for the rest of the transaction.

After the cache refresh, the attacker sent UZD back into the rigid pools. The trace shows that much larger value amounts now mapped back to nearly the same nominal quantities:

UZD::cacheAssetPrice()
emit ConvertedToRigid(owner: 0x68934F60758243eafAf4D2cFeD27BF8010bede3a,
    value: 14158424568430362969746046,
    nominal: 4473152603573241934140372)
emit ConvertedToRigid(owner: 0xfC636D819d1a98433402eC9dEC633d864014F28C,
    value: 2744533299396091494181756,
    nominal: 867096209359408473444805)

That is the breakpoint in concrete terms. The first pool leg converted out 4.082036167832726632955764e24 UZD and later required 1.4158424568430362969746046e25 UZD to relock essentially the same nominal amount. Because the protocol recomputed nominal amounts from a mutable global cache rather than preserving per-position basis, the attacker could manufacture surplus UZD value and then unwind it into external assets.

5. Adversary Flow Analysis

The adversary cluster consisted of EOA 0x5f4c21c9bb73c8b4a296cc256c0cde324db146df, which sent the exploit transaction and received final profit, and helper contract 0xa21a2b59d80dc42d332f778cbb9ea127100e5d75, which performed the on-chain execution.

The execution flow was:

  1. Borrow 7,000,000 USDT from the Uniswap V3 flash pool and 7,000,000 USDC + 10,011 WETH from Balancer.
  2. Add USDC to FRAXBP, obtain FRAXBP LP, and exchange through rigid-address Curve pools to pull UZD out while the stale cached price still applied.
  3. Use WETH and USDT to push the WETH/SDT and USDT/WETH markets that feed UZD's oracle path, then call cacheAssetPrice() to lock in the higher price.
  4. Send UZD back into the same rigid pools at the inflated cache so that the same nominal claim is restored against a much larger rigid face value.
  5. Remove liquidity, swap residual assets back into USDC, USDT, and WETH, repay both flash loans, unwrap remaining WETH, and deliver net profit to the originating EOA.

The exploit required no privileged roles, no private keys, and no attacker-only artifacts beyond a locally deployed helper contract. Every dependency was public on-chain liquidity and public protocol state.

6. Impact & Losses

The balance-diff artifact shows the sender EOA increased from 34.939317878766720541 ETH to 1183.787093674174343229 ETH, a net gain of 1148.847775795407622688 ETH after gas. The same transaction also left 1265.457045 USDT as additional residual profit. These figures are lower-bound measurable proceeds directly backed by the collected balance diff.

The loss was borne by UZD-integrated rigid pools whose balances were revalued against the manipulated cached price. In effect, value was extracted from protocol-integrated liquidity because the rigid/elastic conversion logic allowed cached-price changes to rewrite the value of existing claims.

7. References

  • Exploit transaction: 0x0788ba222970c7c68a738b0e08fb197e669e61f9b226ceec4cab9b85abe8cceb
  • Victim token: 0xb40b6608b2743e691c9b54ddbdee7bf03cd79f1c (UZD)
  • Related protocol address: 0x2ffcc661011bec72e1a9524e12060983e74d14ce (Zunami)
  • Rigid pools: 0x68934f60758243eafaf4d2cfed27bf8010bede3a and 0xfc636d819d1a98433402ec9dec633d864014f28c
  • Flash-loan venues: Uniswap V3 pool 0x3416cf6c708da44db2624d63ea0aaef7113527c6, Balancer vault 0xBA12222222228d8Ba445958a75a0704d566BF2C8
  • Evidence used: exploit metadata, full transaction trace, balance diff, PricableAsset.sol, and ElasticERC20RigidExtension.sol