Analysis of the ParaSwap Exploit

10 min read

Learn how ParaSwap was exploited, resulting in a loss of assets worth approximately $24,000.

TL;DR#

On March 20, 2024, ParaSwap was exploited on the Polygon, Arbitrum, and Ethereum Mainnet, which resulted in a loss of assets worth approximately $24,000.

Introduction to ParaSwap#

ParaSwap is a middleware for traders and decentralized applications.

Vulnerability Assessment#

The root cause of the exploit is a vulnerability in the contract's handling of external callback functions, which allowed unauthorized redirection of funds.

Steps#

Step 1:

We attempt to analyze one of the attack transactions executed by the exploiter.

Step 2:

On March 18, 2024, ParaSwap announced the launch of the Augustus V6 with promises of enhanced swapping efficiency and lower gas fees. These contracts were written in Assembly, thereby promising a heavily gas-optimized state.

This vulnerability targeted users who had provided approvals for this Augustus V6 contract.

Step 3:

The uniswapV3SwapCallback function in the Uniswap V3 Pool ensures the correct movement of the exchanged funds upon completion of the transaction. The caller can enter the constructed fund-sending address through the controllable parameter data in the swap method.

/// @inheritdoc IUniswapV3PoolActions
function swap(address recipient, bool zeroForOne, int256 amountSpecified, uint160 sqrtPriceLimitX96, bytes calldata data) external override noDelegateCall returns (int256 amount0, int256 amount1) {
  require(amountSpecified != 0, "AS");

  Slot0 memory slot0Start = slot0;

  require(slot0Start.unlocked, "LOK");
  require(
    zeroForOne
      ? sqrtPriceLimitX96 < slot0Start.sqrtPriceX96 && sqrtPriceLimitX96 > TickMath.MIN_SQRT_RATIO
      : sqrtPriceLimitX96 > slot0Start.sqrtPriceX96 && sqrtPriceLimitX96 < TickMath.MAX_SQRT_RATIO,
    "SPL"
  );

  slot0.unlocked = false;

  SwapCache memory cache = SwapCache({
    liquidityStart: liquidity,
    blockTimestamp: _blockTimestamp(),
    feeProtocol: zeroForOne ? (slot0Start.feeProtocol % 16) : (slot0Start.feeProtocol >> 4),
    secondsPerLiquidityCumulativeX128: 0,
    tickCumulative: 0,
    computedLatestObservation: false
  });

  bool exactInput = amountSpecified > 0;

  SwapState memory state = SwapState({
    amountSpecifiedRemaining: amountSpecified,
    amountCalculated: 0,
    sqrtPriceX96: slot0Start.sqrtPriceX96,
    tick: slot0Start.tick,
    feeGrowthGlobalX128: zeroForOne ? feeGrowthGlobal0X128 : feeGrowthGlobal1X128,
    protocolFee: 0,
    liquidity: cache.liquidityStart
  });

  // continue swapping as long as we haven't used the entire input/output and haven't reached the price limit
  while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) {
    StepComputations memory step;

    step.sqrtPriceStartX96 = state.sqrtPriceX96;

    (step.tickNext, step.initialized) = tickBitmap.nextInitializedTickWithinOneWord(state.tick, tickSpacing, zeroForOne);

    // ensure that we do not overshoot the min/max tick, as the tick bitmap is not aware of these bounds
    if (step.tickNext < TickMath.MIN_TICK) {
      step.tickNext = TickMath.MIN_TICK;
    } else if (step.tickNext > TickMath.MAX_TICK) {
      step.tickNext = TickMath.MAX_TICK;
    }

    // get the price for the next tick
    step.sqrtPriceNextX96 = TickMath.getSqrtRatioAtTick(step.tickNext);

    // compute values to swap to the target tick, price limit, or point where input/output amount is exhausted
    (state.sqrtPriceX96, step.amountIn, step.amountOut, step.feeAmount) = SwapMath.computeSwapStep(
      state.sqrtPriceX96,
      (zeroForOne ? step.sqrtPriceNextX96 < sqrtPriceLimitX96 : step.sqrtPriceNextX96 > sqrtPriceLimitX96) ? sqrtPriceLimitX96 : step.sqrtPriceNextX96,
      state.liquidity,
      state.amountSpecifiedRemaining,
      fee
    );

    if (exactInput) {
      state.amountSpecifiedRemaining -= (step.amountIn + step.feeAmount).toInt256();
      state.amountCalculated = state.amountCalculated.sub(step.amountOut.toInt256());
    } else {
      state.amountSpecifiedRemaining += step.amountOut.toInt256();
      state.amountCalculated = state.amountCalculated.add((step.amountIn + step.feeAmount).toInt256());
    }

    // if the protocol fee is on, calculate how much is owed, decrement feeAmount, and increment protocolFee
    if (cache.feeProtocol > 0) {
      uint256 delta = step.feeAmount / cache.feeProtocol;
      step.feeAmount -= delta;
      state.protocolFee += uint128(delta);
    }

    // update global fee tracker
    if (state.liquidity > 0) state.feeGrowthGlobalX128 += FullMath.mulDiv(step.feeAmount, FixedPoint128.Q128, state.liquidity);

    // shift tick if we reached the next price
    if (state.sqrtPriceX96 == step.sqrtPriceNextX96) {
      // if the tick is initialized, run the tick transition
      if (step.initialized) {
        // check for the placeholder value, which we replace with the actual value the first time the swap
        // crosses an initialized tick
        if (!cache.computedLatestObservation) {
          (cache.tickCumulative, cache.secondsPerLiquidityCumulativeX128) = observations.observeSingle(
            cache.blockTimestamp,
            0,
            slot0Start.tick,
            slot0Start.observationIndex,
            cache.liquidityStart,
            slot0Start.observationCardinality
          );
          cache.computedLatestObservation = true;
        }
        int128 liquidityNet = ticks.cross(
          step.tickNext,
          (zeroForOne ? state.feeGrowthGlobalX128 : feeGrowthGlobal0X128),
          (zeroForOne ? feeGrowthGlobal1X128 : state.feeGrowthGlobalX128),
          cache.secondsPerLiquidityCumulativeX128,
          cache.tickCumulative,
          cache.blockTimestamp
        );
        // if we're moving leftward, we interpret liquidityNet as the opposite sign
        // safe because liquidityNet cannot be type(int128).min
        if (zeroForOne) liquidityNet = -liquidityNet;

        state.liquidity = LiquidityMath.addDelta(state.liquidity, liquidityNet);
      }

      state.tick = zeroForOne ? step.tickNext - 1 : step.tickNext;
    } else if (state.sqrtPriceX96 != step.sqrtPriceStartX96) {
      // recompute unless we're on a lower tick boundary (i.e. already transitioned ticks), and haven't moved
      state.tick = TickMath.getTickAtSqrtRatio(state.sqrtPriceX96);
    }
  }

  // update tick and write an oracle entry if the tick change
  if (state.tick != slot0Start.tick) {
    (uint16 observationIndex, uint16 observationCardinality) = observations.write(
      slot0Start.observationIndex,
      cache.blockTimestamp,
      slot0Start.tick,
      cache.liquidityStart,
      slot0Start.observationCardinality,
      slot0Start.observationCardinalityNext
    );
    (slot0.sqrtPriceX96, slot0.tick, slot0.observationIndex, slot0.observationCardinality) = (state.sqrtPriceX96, state.tick, observationIndex, observationCardinality);
  } else {
    // otherwise just update the price
    slot0.sqrtPriceX96 = state.sqrtPriceX96;
  }

  // update liquidity if it changed
  if (cache.liquidityStart != state.liquidity) liquidity = state.liquidity;

  // update fee growth global and, if necessary, protocol fees
  // overflow is acceptable, protocol has to withdraw before it hits type(uint128).max fees
  if (zeroForOne) {
    feeGrowthGlobal0X128 = state.feeGrowthGlobalX128;
    if (state.protocolFee > 0) protocolFees.token0 += state.protocolFee;
  } else {
    feeGrowthGlobal1X128 = state.feeGrowthGlobalX128;
    if (state.protocolFee > 0) protocolFees.token1 += state.protocolFee;
  }

  (amount0, amount1) = zeroForOne == exactInput
    ? (amountSpecified - state.amountSpecifiedRemaining, state.amountCalculated)
    : (state.amountCalculated, amountSpecified - state.amountSpecifiedRemaining);

  // do the transfers and collect payment
  if (zeroForOne) {
    if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1));

    uint256 balance0Before = balance0();
    IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
    require(balance0Before.add(uint256(amount0)) <= balance0(), "IIA");
  } else {
    if (amount0 < 0) TransferHelper.safeTransfer(token0, recipient, uint256(-amount0));

    uint256 balance1Before = balance1();
    IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
    require(balance1Before.add(uint256(amount1)) <= balance1(), "IIA");
  }

  emit Swap(msg.sender, recipient, amount0, amount1, state.sqrtPriceX96, state.liquidity, state.tick);
  slot0.unlocked = true;
}

Step 4:

When the uniswapV3SwapCallback method is called in the affected UniswapV3Utils contract within AugustusV6, the attacker is able to enter any address in the `fromAddress` that has authorized funds and transfer funds from this address to the address controlled by them.

// @inheritdoc IUniswapV3SwapCallback
function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external {
  uint256 uniswapV3FactoryAndFF = UNISWAP_V3_FACTORY_AND_FF;
  uint256 uniswapV3PoolInitCodeHash = UNISWAP_V3_POOL_INIT_CODE_HASH;
  address permit2Address = PERMIT_2;
  bool isPermit2 = data.length == 512;
  // Check if data length is greater than 160 bytes (1 pool)
  // We pass multiple pools in data when executing a multi-hop swapExactAmountOut
  if (data.length > 160 && !isPermit2) {
    // Initialize recursive variables
    address payer;
    // solhint-disable-next-line no-inline-assembly
    assembly {
      // Copy payer address from calldata
      payer := calldataload(164)
    }

    // Recursive call swapExactAmountOut
    _callUniswapV3PoolsSwapExactAmountOut(amount0Delta > 0 ? -amount0Delta : -amount1Delta, data, payer);
  } else {
    // solhint-disable-next-line no-inline-assembly
    assembly {
      // Token to send to the pool
      let token
      // Amount to send to the pool
      let amount
      // Pool address
      let poolAddress := caller()

      // Get free memory pointer
      let ptr := mload(64)

      // We need make sure the caller is a UniswapV3Pool deployed by the canonical UniswapV3Factory
      // 1. Prepare data for calculating the pool address
      // Store ff+factory address, Load token0, token1, fee from bytes calldata and store pool init code hash

      // Store 0xff + factory address (right padded)
      mstore(ptr, uniswapV3FactoryAndFF)

      // Store data offset + 21 bytes (UNISWAP_V3_FACTORY_AND_FF SIZE)
      let token0Offset := add(ptr, 21)

      // Copy token0, token1, fee to free memory pointer + 21 bytes (UNISWAP_V3_FACTORY_AND_FF SIZE) + 1 byte
      // (direction)
      calldatacopy(add(token0Offset, 1), add(data.offset, 65), 95)

      // 2. Calculate the pool address
      // We can do this by first calling the keccak256 function on the fetched values and then
      // calculating keccak256(abi.encodePacked(hex'ff', address(factory_address),
      // keccak256(abi.encode(token0,
      // token1, fee)), POOL_INIT_CODE_HASH));
      // The first 20 bytes of the computed address are the pool address

      // Calculate keccak256(abi.encode(address(token0), address(token1), fee))
      mstore(token0Offset, keccak256(token0Offset, 96))
      // Store POOL_INIT_CODE_HASH
      mstore(add(token0Offset, 32), uniswapV3PoolInitCodeHash)
      // Calculate keccak256(abi.encodePacked(hex'ff', address(factory_address), keccak256(abi.encode(token0,
      // token1, fee)), POOL_INIT_CODE_HASH));
      mstore(ptr, keccak256(ptr, 85)) // 21 + 32 + 32

      // Get the first 20 bytes of the computed address
      let computedAddress := and(mload(ptr), 0xffffffffffffffffffffffffffffffffffffffff)

      // Check if the caller matches the computed address (and revert if not)
      if xor(poolAddress, computedAddress) {
        mstore(0, 0x48f5c3ed00000000000000000000000000000000000000000000000000000000) // store the selector
        // (error InvalidCaller())
        revert(0, 4) // revert with error selector
      }

      // If the caller is the computed address, then we can safely assume that the caller is a UniswapV3Pool
      // deployed by the canonical UniswapV3Factory

      // 3. Transfer amount to the pool

      // Check if amount0Delta or amount1Delta is positive and which token we need to send to the pool
      if sgt(amount0Delta, 0) {
        // If amount0Delta is positive, we need to send amount0Delta token0 to the pool
        token := and(calldataload(add(data.offset, 64)), 0xffffffffffffffffffffffffffffffffffffffff)
        amount := amount0Delta
      }
      if sgt(amount1Delta, 0) {
        // If amount1Delta is positive, we need to send amount1Delta token1 to the pool
        token := calldataload(add(data.offset, 96))
        amount := amount1Delta
      }

      // Based on the data passed to the callback, we know the fromAddress that will pay for the
      // swap, if it is this contract, we will execute the transfer() function,
      // otherwise, we will execute transferFrom()

      // Check if fromAddress is this contract
      let fromAddress := calldataload(164)

      switch eq(fromAddress, address())
      // If fromAddress is this contract, execute transfer()
      case 1 {
        // Prepare external call data
        mstore(ptr, 0xa9059cbb00000000000000000000000000000000000000000000000000000000) // store the
        // selector
        // (function transfer(address recipient, uint256 amount))
        mstore(add(ptr, 4), poolAddress) // store the recipient
        mstore(add(ptr, 36), amount) // store the amount
        let success := call(gas(), token, 0, ptr, 68, 0, 32) // call transfer
        if success {
          switch returndatasize()
          // check the return data size
          case 0 {
            success := gt(extcodesize(token), 0)
          }
          default {
            success := and(gt(returndatasize(), 31), eq(mload(0), 1))
          }
        }

        if iszero(success) {
          mstore(0, 0x1bbb4abe00000000000000000000000000000000000000000000000000000000) // store the
          // selector
          // (error CallbackTransferFailed())
          revert(0, 4) // revert with error selector
        }
      }
      // If fromAddress is not this contract, execute transferFrom() or permitTransferFrom()
      default {
        switch isPermit2
        // If permit2 is not present, execute transferFrom()
        case 0 {
          mstore(ptr, 0x23b872dd00000000000000000000000000000000000000000000000000000000) // store the
          // selector
          // (function transferFrom(address sender, address recipient,
          // uint256 amount))
          mstore(add(ptr, 4), fromAddress) // store the sender
          mstore(add(ptr, 36), poolAddress) // store the recipient
          mstore(add(ptr, 68), amount) // store the amount
          let success := call(gas(), token, 0, ptr, 100, 0, 32) // call transferFrom
          if success {
            switch returndatasize()
            // check the return data size
            case 0 {
              success := gt(extcodesize(token), 0)
            }
            default {
              success := and(gt(returndatasize(), 31), eq(mload(0), 1))
            }
          }
          if iszero(success) {
            mstore(0, 0x1bbb4abe00000000000000000000000000000000000000000000000000000000) // store the
            // selector
            // (error CallbackTransferFailed())
            revert(0, 4) // revert with error selector
          }
        }
        // If permit2 is present, execute permitTransferFrom()
        default {
          // Otherwise Permit2.permitTransferFrom
          // Store function selector
          mstore(ptr, 0x30f28b7a00000000000000000000000000000000000000000000000000000000)
          // permitTransferFrom()
          calldatacopy(add(ptr, 4), 292, 352) // Copy data to memory
          mstore(add(ptr, 132), poolAddress) // Store pool address as recipient
          mstore(add(ptr, 164), amount) // Store amount as amount
          // Call permit2.permitTransferFrom and revert if call failed
          if iszero(call(gas(), permit2Address, 0, ptr, 356, 0, 0)) {
            mstore(0, 0x6b836e6b00000000000000000000000000000000000000000000000000000000) // Store
            // error selector
            // error Permit2Failed()
            revert(0, 4)
          }
        }
      }
    }
  }
}

Step 5:

The quick action of the team and the intervention of the white-hat response helped secure funds worth $2.95 million across multiple chains, all of which were held at this address at the time of this writing. Reportedly, the actual loss due to the exploit stands around $24,000.

Ethereum: $1,592,999
Polygon: $284,737
Arbitrum One: $284,737
BNB Chain: $166,877
Avalanche: $160,570
Optimism: $20,977

Aftermath#

The team acknowledged the occurrence of the exploit and stated that they took immediate action by pausing the V6 API and conducting white-hat rescue in order to secure the funds for users who were at risk of being exploited.

Solution#

Addressing the vulnerability exploited in the ParaSwap incident involves a combination of immediate response actions and longer-term preventative strategies. Users should start by revoking any previous approvals granted to the affected contracts, effectively reducing the risk of unauthorized fund transfers by limiting the contracts' access to users' assets. Additionally, temporarily pausing the functionality of vulnerable contracts can prevent further exploitation while the team works on addressing the security flaws.

At the contract level, enhancing security involves several key steps. Firstly, access to callback functions, particularly those handling fund transfers or critical logic, should be restricted to ensure that only authorized contracts or addresses can invoke these functions, preventing arbitrary external calls from exploiting the callback functionality. It's also crucial to incorporate checks within callback functions to validate the caller's permissions explicitly. Before any fund transfer, the contract should verify that the interacting address has explicitly granted permission for the operation, typically through the use of conditional statements that check for authorization.

Regular, comprehensive security audits by third-party experts can uncover potential vulnerabilities, and making audit reports publicly available encourages community scrutiny and trust. Together, these measures form a robust approach to enhancing security and preventing similar exploits in the future.

However even in an environment bolstered by stringent security measures, the potential for vulnerabilities to be exploited cannot be completely eradicated. In such instances, the value of partnering with entities like Neptune Mutual becomes evident. Neptune Mutual offers a crucial layer of protection by helping to create a dedicated cover pool designed to mitigate the fallout from incidents akin to the ParaSwap exploit. Focused on addressing the unique challenges posed by smart contract vulnerabilities, Neptune Mutual crafts parametric policies that specifically cater to these risks.

Partnering with Neptune Mutual streamlines the recovery journey for users by eliminating the exhaustive requirement for proof of loss documentation. Following the confirmation and resolution of an incident within our comprehensive incident resolution framework, our focus promptly shifts towards the swift disbursement of compensation and financial support to those impacted. This method ensures that users affected by such security shortcomings receive quick and effective assistance.

Our services span across several of the leading blockchain ecosystems, including EthereumArbitrum, and the BNB chain. This extensive reach empowers us to provide a safety net for a diverse array of DeFi participants, safeguarding against a variety of vulnerabilities.

Reference Source ParaSwap

By

Tags