USM Protocol 经济模型漏洞分析

2026-08-21
  • 交易 Hash 0xfae5e751b8ce01457cbb6b529839f24a0cff50faaabcbd0fd02ca0cf559b050e
  • 损失 ~70.83 ETH
  • 事故还原:Attacker 利用 USM Protocol 设计的经济模型上的漏洞实现了攻击,首先利用 FlashLoan 借入了 11579 ETH,随后使用 fund() 向 USM Protocol 合约账户存入这 11579 ETH,紧接着分 64 次使用 defund() 取出,每次取出约 182 ETH ,最终获利约 70.83 ETH。
  • 漏洞剖析:Etherscan 上的 USM Protocol 合约源码 中可以看到 USM Protocol 的源代码。

主要问题出现在下面这两段代码上:

 function ethFromDefund(LoadedState memory ls, uint fumSupply, uint fumIn)

        public pure returns (uint ethOut, uint adjShrinkFactor)

    {

        // Burn FUM at a sliding-down FUM price.  Our approximation technique here resembles the one in fumFromFund() above,

        // but we need to be even more clever this time...

  

        // 1. Calculating the initial FUM sell price we start from is no problem:

        uint adjustedEthUsdPrice0 = adjustedEthUsdPrice(IUSM.Side.Sell, ls.ethUsdPrice, ls.bidAskAdjustment);

        uint fumSellPrice0 = fumPrice(IUSM.Side.Sell, adjustedEthUsdPrice0, ls.ethPool, ls.usmTotalSupply, fumSupply, false);

  

        { // Scope for adjShrinkFactor, to avoid the dreaded "stack too deep" error.  Thanks Uniswap v2 for the trick!

            // 2. Now we want a "pessimistic" lower bound on the ending ETH pool qty.  We can get this by supposing the entire

            // burn happened at our initial fumSellPrice0: this is "optimistic" in terms of how much ETH we'd get back, but

            // "pessimistic" in the sense we want - how much ETH would be left in the pool:

            uint lowerBoundEthQty1 = ls.ethPool - fumIn.wadMulUp(fumSellPrice0);

            uint lowerBoundEthShrinkFactor1 = lowerBoundEthQty1.wadDivDown(ls.ethPool);

  

            // 3. From this "pessimistic" lower bound on the ending ETH qty, and a similarly pessimistic netFumDelta value of 4

            // (the netFumDelta when debt ratio is at the highest value it can end at here, MAX_DEBT_RATIO), we can calculate a

            // "pessimistic" lower bound on our ending adjustedEthUsdPrice, ie, overstating how large an impact our burn could

            // have on the ETH/USD price used to calculate our FUM sell price:

            uint adjustedEthUsdPrice1 = adjustedEthUsdPrice0.wadMulDown(lowerBoundEthShrinkFactor1.wadPowDown(FOUR_WAD));

  

            // 4. From adjustedEthUsdPrice1, we can calculate a pessimistic (upper-bound) debtRatio2 we'll end up at after the

            // defund operation, and from debtRatio2, a pessimistic (upper-bound) netFumDelta2 we can hold fixed during the

            // calculation:

            uint debtRatio1 = debtRatio(adjustedEthUsdPrice1, lowerBoundEthQty1, ls.usmTotalSupply);

            uint debtRatio2 = debtRatio1.wadMin(MAX_DEBT_RATIO);    // defund() fails anyway if dr ends > MAX, so cap it at MAX

            uint netFumDelta2;

            unchecked { netFumDelta2 = WAD.wadDivUp(WAD - debtRatio2) - WAD; }

  

            // 5. Combining lowerBoundEthShrinkFactor1 and netFumDelta2 gives us our final, pessimistic adjShrinkFactor, from

            // our standard formula adjChangeFactor = ethChangeFactor**(netFumDelta / 2):

            unchecked { adjShrinkFactor = lowerBoundEthShrinkFactor1.wadPowDown(netFumDelta2 / 2); }

        }

  

        // 6. And adjShrinkFactor tells us the adjustedEthUsdPrice2 we'll end the operation at, from which we can also

        // calculate the instantaneous FUM sell price we'll end the operation at, just as we calculated our ending fumBuyPrice1

        // in fumFromFund():

        uint adjustedEthUsdPrice2 = adjustedEthUsdPrice0.wadMulDown(adjShrinkFactor.wadSquaredDown());

        uint fumSellPrice2 = fumPrice(IUSM.Side.Sell, adjustedEthUsdPrice2, ls.ethPool, ls.usmTotalSupply, fumSupply, false);

  

        // 7. We now know the starting fumSellPrice0, and the ending fumSellPrice2.  We want to combine these to get a single

        // avgFumSellPrice we can use for the entire defund operation, which will trivially give us ethOut.  But taking the

        // geometric average again, as we did in usmFromMint() and fumFromFund(), is dicey in the defund() case: fumSellPrice2

        // could be arbitrarily close to 0, which would make avgFumSellPrice arbitrarily close to 0, which would have the

        // highly perverse result that a *larger* fumIn returns strictly *less* ETH!  What alternative to geometric average

        // gives the best results here is a complicated problem, but one simple option that avoids the avgFumSellPrice = 0 flaw

        // is to just take the arithmetic average instead, (fumSellPrice0 + fumSellPrice2) / 2:

        uint avgFumSellPrice = fumSellPrice0 + fumSellPrice2;

        unchecked { avgFumSellPrice /= 2; }

        ethOut = fumIn.wadMulDown(avgFumSellPrice);

    }
    function fumFromFund(LoadedState memory ls, uint fumSupply, uint ethIn, uint debtRatio_, bool prefund)

        public pure returns (uint fumOut, uint adjGrowthFactor)

    {

        uint adjustedEthUsdPrice0 = adjustedEthUsdPrice(IUSM.Side.Buy, ls.ethUsdPrice, ls.bidAskAdjustment);

        uint fumBuyPrice0 = fumPrice(IUSM.Side.Buy, adjustedEthUsdPrice0, ls.ethPool, ls.usmTotalSupply, fumSupply, prefund);

        if (prefund) {

            // We're in the prefund period, so no fees - fumOut is just ethIn divided by the fixed prefund FUM price:

            adjGrowthFactor = WAD;

            fumOut = ethIn.wadDivDown(fumBuyPrice0);

        } else {

            // Create FUM at a sliding-up FUM price.  We follow the same broad strategy as in usmFromMint(): the effective ETH

            // price increases smoothly during the fund() operation, proportionally to the fraction by which the ETH pool

            // grows.  But there are a couple of extra nuances in the FUM case:

            //

            // 1. FUM is "leveraged"/"higher-delta" ETH, so minting 1 ETH worth of FUM should move the price by more than

            //    minting 1 ETH worth of USM does.  (More by a "net FUM delta" factor.)

            // 2. The theoretical FUM price is based on the ETH buffer (excess ETH beyond what's needed to cover the

            //    outstanding USM), which is itself affected by/during this fund operation...

            //

            // The code below uses a "reasonable approximation" to deal with those complications.  See also the discussion in:

            // https://jacob-eliosoff.medium.com/usm-minimalist-decentralized-stablecoin-part-4-fee-math-decisions-a5be6ecfdd6f

  

            { // Scope for adjGrowthFactor, to avoid the dreaded "stack too deep" error.  Thanks Uniswap v2 for the trick!

                // 1. Start by calculating the "net FUM delta" described above - the factor by which this operation will move

                // the ETH price more than a simple mint() operation would.  Calculating the pure, fluctuating theoretical

                // delta is a mess: we calculate the initial delta and pretend it stays fixed thereafter.  The theoretical

                // delta *decreases* during a fund() call (as the pool grows, the ETH/USD price increases, and FUM becomes less

                // leveraged), so holding it fixed at its initial value is "pessimistic", like we want - ensures we charge more

                // fees than the theoretical amount, not less.

                uint effectiveDebtRatio0 = debtRatio_.wadMin(MAX_DEBT_RATIO);

                uint netFumDelta;

                unchecked { netFumDelta = effectiveDebtRatio0.wadDivUp(WAD - effectiveDebtRatio0); }

  

                // 2. Given the delta, we can calculate the adjGrowthFactor (price impact): for mint() (delta 1), the factor

                // was poolChangeFactor**(1 / 2); now instead we use poolChangeFactor**(netFumDelta / 2).

                uint ethPool1 = ls.ethPool + ethIn;

                unchecked { adjGrowthFactor = ethPool1.wadDivUp(ls.ethPool).wadPowUp(netFumDelta / 2); }

            }

  

            // 3. Here we use the same simplifying trick as usmFromMint() above: we pretend our entire FUM purchase is done at

            // a single fixed price.  For that fixed FUM price, we again use the trick of taking the geometric average of the

            // starting and ending FUM buy prices, which we can calculate exactly now that we know the ending ETH pool quantity

            // (from ethIn) and the ending adjusted ETH/USD price (from the adjGrowthFactor calculated above).  This geometric

            // average isn't as accurate an approximation of the theoretical integral here as it was for usmFromMint(), since

            // the FUM buy price follows a less predictable curve than the USM buy price, but it's close enough for our

            // purposes: we mostly just want to charge funders a positive fee, that increases as a % of ethIn as ethIn gets

            // larger ("larger trades pay superlinearly larger fees").

            uint adjustedEthUsdPrice1 = adjustedEthUsdPrice0.wadMulUp(adjGrowthFactor.wadSquaredUp());

            uint fumBuyPrice1 = fumPrice(IUSM.Side.Buy, adjustedEthUsdPrice1, ls.ethPool, ls.usmTotalSupply, fumSupply,

                                         prefund);

            uint avgFumBuyPrice = fumBuyPrice0.wadMulUp(fumBuyPrice1).wadSqrtUp();      // Taking the geometric avg

            fumOut = ethIn.wadDivDown(avgFumBuyPrice);

        }

    }

fund() 的时候使用的几何平均数,defund() 的时候使用的算数平均数

形象化描述:杠杆机制

下面用资金池、承重结构与弹簧的类比,把 USM/FUM 的价值分层和杠杆来源串起来。

杠杆不是抽象的数字游戏,而是极其直观的物理力学

资金池中的双子星:USM 稳定币与 FUM 杠杆代币

价值的俄罗斯方块:初始资金池的物理空间

现货上涨百分之二十时顶部权益空间的放大

现货下跌百分之十时剩余权益空间的收缩

钢筋与弹簧定律:风险与收益的定向传导

拆解底层引擎:代数重构资金池初始状态

债务的相对性:ETH 价格上涨时的价值变化

揭示乘数因子:FUM 收益被放大的原因

杠杆源于资产结构中的空间挤压