本文分析了 Euler Finance 于 2023 年 3 月 13 日遭受的攻击事件,攻击者利用 Euler Finance 在 donateToReserves() 函数中缺少流动性检查的漏洞,通过闪电贷和自清算,从 Euler Finance 获利约 1.97 亿美元。文章深入剖析了漏洞原理、攻击过程,并提出了防止类似事件再次发生的建议。
2025年6月16日
UTC 时间 08:50:59,哈希值为 0xc310a0affe2169d1f6feec1c63dbc7f7c62a887fa48795d327d4d2da2d6b111d 的交易
在区块 16 822 133 中落地。一个单独的外部所有账户 0xb66cd966670d962c227b3eaba30a872dbfb995db 从 Aave 借了 3000 万 DAI,直接将其导入 Euler 的借贷池,然后带着价值约 1.97 亿美元的抵押品走了出来。新闻媒体后来将其总结为闪电贷攻击。
有点正确,但这个标签掩盖了最重要的事实:小偷既没有利用预言机,也没有通过重入漏洞溜走。他们只是使用了 Euler 七个月前添加的公共函数 donateToReserves(),并依靠 Euler 自己的清算引擎来完成这项工作。
Euler 允许任何人存入资产并获得 eToken(他们对池子的债权)。用户可以根据该抵押品进行借款,收到一个随着利息增长的 dToken。由于这两种代币都是 ERC-20,用户可以递归地借给自己:存款、借款、再存入借款,以此类推,大约可将风险敞口扩大 19 倍。Euler 的风险引擎会跟踪一个健康因子;如果它跌破 1,则该账户可以以很大的折扣进行清算。
2022 年 7 月,治理部门合并了一个 pull-request EIP-14。它引入了 donateToReserves(),旨在作为一个善意的按钮:鲸鱼可以燃烧掉它的一些抵押品,为其他所有人增加储备。该函数燃烧 eToken 并在内部储备计数器中添加相同的数量,但是,致命的是,它在燃烧后没有调用 checkLiquidity()。如果一个高杠杆用户按下该按钮,引擎会一直认为该账户有偿付能力,直到下一个指令,此时清算就变得合法了。Omniscia 的事后分析称之为“由疏忽造成的坏账”。
表面上看起来无辜且是一种善意的方法,donateToReserves() 在没有刷新捐赠者的健康因子的情况下燃烧了 eToken:
function donateToReserves(uint subAccountId, uint amount)
external nonReentrant
{
_burn(msg.sender, amount); // eTokens destroyed
reserves += amount; // protocol feels richer
// ❌ Missing: checkLiquidity(msg.sender);
}
以攻击者的身份写作,我那天早上的笔记本专注于区块编号 [16 822 133],(这里的时间是区块级别,而不是挂钟时间):
T – 00 s – 从 Aave V2 提取 30 000 000 DAI,如果我在同一个区块内结算,只需支付九个基点的费用。
T + 03 s – 将 20 000 000 DAI 存入 Euler;收到 19 568 124 eDAI。
T + 07 s – 循环:借入 195 681 244 DAI,再存入,再次借入,十次通过。健康因子浮动在 1.02——完全合法。
T + 10 s – 按下 donateToReserves(100 000 000 eDAI)。健康因子猛降至 0.77。警报静默;函数不检查。
T + 11 s – 从辅助合约 0xb2698c2d99ad2c302a95a8db26b08d17a77cedd4 触发清算,以 17% 的折扣购买我自己的坏账。
T + 20 s – 提取被扣押的抵押品(DAI、wBTC、wstETH、USDC),偿还 Aave 贷款加上费用,在这个资产上的净利润 ≈ 8 900 000 DAI;在矿工注意到之前,在其他四个池子上重复该模式。
T + 90 s – 将钱包余额清空到 Tornado Cash,然后等待新闻周期。
一次捐赠就打破了平衡;其他一切都是机械的清算和会计漂移。没有预言机尖峰,没有重入,没有治理延迟——只是协议逻辑被武器化来对付自己。BlockSec 的 Phalcon 仪表板在几分钟内就发出了漏洞利用标志,但交易已经完成。
目标:证明删除一个 require 允许清算你自己的债务以获得即时利润。
使用 Balancer 的 Vault,因为它的单资产闪电贷只需一次调用;我们只需要 wstETH 一次:
IERC20[] tokens;
tokens[0] = IERC20(DAI);uint256[] amounts;
amounts[0] = 30_000_000 ether;vault.flashLoan(this, tokens, amounts, "");
assertGt( IERC20(DAI).balanceOf(address(this)),
8_000_000 ether, // 保守的下限
“profit too small – exploit failed”);
assertEq( EToken(eDAI).balanceOf(address(this)), 0,
“all collateral withdrawn”);
我们关心的不变量:盈利能力 ≥ 8 M DAI 且没有剩余债务——与链上满足的条件相同。
donateToReserves 内部的偿付能力重新检查 —
从字面上看,一个 require(health >= 1) 就会使捐赠失败。
不变量模糊测试 断言每次外部调用后 totalCollateral ≥ totalDebt。
一个 30 秒的 Echidna 运行会立即标记捐赠者路径。
看起来无害的权限(“燃烧我自己的抵押品,帮助协议!”)可能比奇异的重入更致命。如果一个函数改变了用户的余额,总是在同一个调用的最后重新计算偿付能力——没有例外,没有神圣的奶牛。
参考文献
(全文引用的区块号、tx 哈希、代码库和分析)
日记关闭——直到下一次违规。
- 原文链接: blog.immunebytes.com/202...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!