TL;DR

In early April, our whitehat i2huer (Purdue University) from Pwned No More (PNM) DAO reported a critical security vulnerability to the bug bounty program of Apex Protocol, where a potential vulnerability of Uniswap v3 TWAP oracle manipulation allows a direct theft of more than 40% user funds. Over $8M assets are exposed at risk. The ApeX Protocol is highly responsive to the bugfix.

ApeX Protocol

ApeX is a decentralized and non-custodial derivatives protocol that facilitates the creation of perpetual swap markets for any token pair. For each pair of ERC20 tokens, the protocol creates a virtual AMM where users can add liquidity (as liquidity providers) and open positions (as investors). Different from the traditional AMM, e.g., Uniswap v3, ApeX AMM only holds the real assets on one side, namely BaseToken. Another token is hence named as QuoteToken. For illustrative purpose, let’s consider a WBTC-USDC ApeX AMM where the BaseToken and QuoteToken is WBTC and USDC, respectively.

In case of the virtual AMM deviating from the real-world price too much, each virtual AMM is piggy-back with a Uniswap v3 pool. If needed (i.e., the price gap reaches a pre-set threshold), the price of virtual AMM will be calibrated and forcibly aligned with the corresponding Uniswap v3 pool. Note that here, ApeX Protocol uses a 15-minute TWAP to defend the naïve flash loan attack.

Vulnerability Analysis

There are three vulnerabilities/design flaws within the codebase, along with which the attacker can directly steal more than 40% user funds.

Calibration Pool Relinking

ApeX protocol uses the following code to link a reference/calibration Uniswap v3 pool to a given virtual AMM. Theoretically, the setupTwap function cannot be invoked twice upon a same AMM, guaranteed by the first checks (i.e., "PriceOracle.setupTwap: ALREADY_SETUP" ).

function setupTwap(address amm) external override {
    require(!ammObservations[amm][0].initialized, "PriceOracle.setupTwap: ALREADY_SETUP");
    address baseToken = IAmm(amm).baseToken();
    address quoteToken = IAmm(amm).quoteToken();

    address pool = getTargetPool(baseToken, quoteToken);
    if (pool != address(0)) {
        _setupV3Pool(baseToken, quoteToken, pool);
    } else {
        pool = getTargetPool(baseToken, WETH);
        require(pool != address(0), "PriceOracle.setupTwap: POOL_NOT_FOUND");
        _setupV3Pool(baseToken, WETH, pool);

        pool = getTargetPool(WETH, quoteToken);
        require(pool != address(0), "PriceOracle.setupTwap: POOL_NOT_FOUND");
        _setupV3Pool(WETH, quoteToken, pool);
    }
 
    ammObservationIndex[amm] = 0;
    ammObservations[amm].initialize(_blockTimestamp());
    ammObservations[amm].grow(1, cardinality);
}