Breaking down the Predy Finance Hack

5 min read

Learn how Predy Finance Token was exploited, resulting in a loss of assets worth $464,000.

TL;DR#

On May 14, 2024, Predy Finance was exploited on the Arbitrum chain due to a smart contract vulnerability, which resulted in a loss of 83.7 ETH and 219,585 USDC, totaling approximately $464,000.

Introduction to Predy Finance#

Predy Finance is a DEX for perpetual trading and token swaps.

Vulnerability Assessment#

The root cause of the exploit is a lack of regulated access control.

Steps#

Step 1:

We attempt to analyse the attack transaction executed by the exploiter.

Step 2:

The exploited contract has a take function that is responsible for transferring the tokens, which is guarded by an onlyByLocker modifier. It checks if the caller of the function is indeed a locker.

/**
 * @notice Takes tokens
 * @dev Only the current locker can call this function
 */
function take(bool isQuoteAsset, address to, uint256 amount) external onlyByLocker {
  globalData.take(isQuoteAsset, to, amount);
}
modifier onlyByLocker() {
  address locker = globalData.lockData.locker;
  if (msg.sender != locker) revert LockedBy(locker);
  _;
}

Step 3:

The value of this locker can be set in the trade function of the contract, which has a callback to the predyTradeAfterCallback function.

function trade(GlobalDataLibrary.GlobalData storage globalData, IPredyPool.TradeParams memory tradeParams, bytes memory settlementData) external returns (IPredyPool.TradeResult memory tradeResult) {
  DataType.PairStatus storage pairStatus = globalData.pairs[tradeParams.pairId];

  // update interest growth
  ApplyInterestLib.applyInterestForToken(globalData.pairs, tradeParams.pairId);

  // update rebalance interest growth
  Perp.updateRebalanceInterestGrowth(pairStatus, pairStatus.sqrtAssetStatus);

  tradeResult = Trade.trade(globalData, tradeParams, settlementData);

  globalData.vaults[tradeParams.vaultId].margin += tradeResult.fee + tradeResult.payoff.perpPayoff + tradeResult.payoff.sqrtPayoff;

  (tradeResult.minMargin, , , tradeResult.sqrtTwap) = PositionCalculator.calculateMinDeposit(pairStatus, globalData.vaults[tradeParams.vaultId], DataType.FeeAmount(0, 0));

  // The caller deposits or withdraws margin from the callback that is called below.
  callTradeAfterCallback(globalData, tradeParams, tradeResult);

  // check vault safety
  tradeResult.minMargin = PositionCalculator.checkSafe(pairStatus, globalData.vaults[tradeParams.vaultId], DataType.FeeAmount(0, 0));

  emit PositionUpdated(tradeParams.vaultId, tradeParams.pairId, tradeParams.tradeAmount, tradeParams.tradeAmountSqrt, tradeResult.payoff, tradeResult.fee);
}

Step 4:

Due to a lack of access control, anyone could add and register a trading pair by using a valid Uniswap pair to the pool.

/**
 * @notice Adds a new trading pair.
 * @param addPairParam AddPairParams struct containing pair information.
 * @return pairId The id of the pair.
 */
function registerPair(AddPairLogic.AddPairParams memory addPairParam) external returns (uint256) {
  return AddPairLogic.addPair(globalData, allowedUniswapPools, addPairParam);
}
/**
 * @notice Adds token pair
 */
function addPair(GlobalDataLibrary.GlobalData storage _global, mapping(address => bool) storage allowedUniswapPools, AddPairParams memory _addPairParam) external returns (uint256 pairId) {
  pairId = _global.pairsCount;

  require(pairId < Constants.MAX_PAIRS, "MAXP");

  IUniswapV3Pool uniswapPool = IUniswapV3Pool(_addPairParam.uniswapPool);

  address stableTokenAddress = _addPairParam.marginId;

  IUniswapV3Factory uniswapV3Factory = IUniswapV3Factory(_global.uniswapFactory);

  // check the uniswap pool is registered in UniswapV3Factory
  if (uniswapV3Factory.getPool(uniswapPool.token0(), uniswapPool.token1(), uniswapPool.fee()) != _addPairParam.uniswapPool) {
    revert InvalidUniswapPool();
  }

  require(uniswapPool.token0() == stableTokenAddress || uniswapPool.token1() == stableTokenAddress, "C3");

  bool isQuoteZero = uniswapPool.token0() == stableTokenAddress;

  _storePairStatus(stableTokenAddress, _global.pairs, pairId, isQuoteZero ? uniswapPool.token1() : uniswapPool.token0(), isQuoteZero, _addPairParam);

  allowedUniswapPools[_addPairParam.uniswapPool] = true;

  _global.pairsCount++;

  emit PairAdded(pairId, _addPairParam.marginId, _addPairParam.uniswapPool);
}

Step 5:

After this locker value is set, within the callback function, the hacker was able to take all the tokens, add liquidity using them, and then withdraw all of these tokens. This callTradeAfterCallback function also doesn’t check to verify the caller of this function.

function callTradeAfterCallback(GlobalDataLibrary.GlobalData storage globalData, IPredyPool.TradeParams memory tradeParams, IPredyPool.TradeResult memory tradeResult) internal {
  globalData.initializeLock(tradeParams.pairId);

  IHooks(msg.sender).predyTradeAfterCallback(tradeParams, tradeResult);

  (int256 marginAmountUpdate, int256 settledBaseAmount) = globalData.finalizeLock();

  if (settledBaseAmount != 0) {
    revert IPredyPool.BaseTokenNotSettled();
  }

  globalData.vaults[tradeParams.vaultId].margin += marginAmountUpdate;

  emit MarginUpdated(tradeParams.vaultId, marginAmountUpdate);
}

Step 6:

At the time of this writing, the exploiter on the Arbitrum chain bridged 100.955 ETH, worth approximately $304,640 across three different transactions, to this address on the Ethereum Mainnet. The remaining parts of the stolen funds, 75.2191 WETH and 4.6006 ETH, which total $240,612, are still held by the attacker in their wallet in the Arbitrum chain.

Aftermath#

The team acknowledged the occurrence of the exploit. They stated that their contract uses Permit2 schemes, which means that none of the users directly approved their assets for the exploited contract. However, they advised users to revoke access to the affected contracts to be on the safer side. 

They also sent an on-chain message to the exploiter proposing a 10% bounty reward in hopes of retrieving the stolen assets. They also shared a post-mortem report of the exploit, which states that they have now disabled the public facing functions of their contract that were used in the attack.

Solution#

To mitigate the vulnerabilities that led to the Predy Finance exploit and prevent future incidents, a comprehensive approach is necessary. Implementing strict access control is crucial, as the primary cause of the exploit was the lack of regulated access control. Role-Based Access Control (RBAC) should be enforced within the smart contract, defining specific roles that have permission to execute critical functions. For functions that handle significant transfers or changes, multisignature requirements should be introduced, requiring multiple trusted parties' signatures before execution.

Enhancing caller verification is also essential. This includes adding comprehensive verification mechanisms to ensure that only legitimate calls are made to critical functions. Strict modifier checks should be enhanced, and a whitelist of approved contract addresses and users who can call sensitive functions should be maintained. Additionally, securing the callback mechanism is vital. The `callTradeAfterCallback` function and similar callbacks must verify the caller’s identity and ensure that the call originates from a legitimate source. This can be achieved through caller verification in callbacks and using secure mechanisms, such as signed messages or nonces, to authorize callback functions.

Restricting pair registration is another important step. The ability to register new trading pairs should be limited to admin roles or trusted addresses, and robust validation checks should be implemented to ensure that only legitimate pairs are registered. Conducting regular security audits is critical to identifying and addressing potential vulnerabilities before they can be exploited. Engaging reputable security firms for regular third-party audits and implementing real-time monitoring and alert systems to detect unusual activities or potential exploits promptly are recommended.

Despite rigorous security protocols, it's impossible to completely eliminate the risk of vulnerability exploitation. In such situations, partnering with Neptune Mutual is crucial. If the Predy Finance team had established a dedicated cover pool with us before the incident, the impact of this exploit could have been significantly reduced. Neptune Mutual specializes in offering coverage for losses due to smart contract vulnerabilities, utilizing parametric policies tailored for these specific risks.

Collaborating with Neptune Mutual simplifies the compensation process for users by reducing the need for extensive proof of loss documentation. Once an incident is confirmed and definitively resolved through our comprehensive incident resolution protocol, we promptly provide financial relief to those affected. This approach ensures that users impacted by security breaches receive swift assistance.

Our marketplace operates across several major blockchain networks, including EthereumArbitrum, and the BNB chain, offering broad support to a diverse range of DeFi users. This extensive coverage enhances our ability to protect against various vulnerabilities, thereby improving the overall security of our wide client base.

Reference Source SlowMist

By

Tags