Taking a Closer Look at Velocore Exploit

8 min read

Learn how Velocore was exploited, resulting in a loss of assets worth approximately $10 million.

TL;DR#

On June 1, 2024, Velocore was exploited on zkSync and Linea due to a smart contract vulnerability, which resulted in a loss of assets worth approximately $10 million.

Introduction to Velocore#

Velocore is a decentralized exchange that operates on the Telos, zkSync Era, and Linea blockchains.

Vulnerability Assessment#

The root cause of the exploit is a logic bug leading to a faulty smart contract implementation.

Steps#

Step 1:

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

Step 2:

As seen in the exploited ConstantProductPool contract, there exists a lack of verification of the Vault address, allowing anyone to directly invoke a call to the velocore__execute function with carefully constructed parameters, thereby manipulating the feeMultiplier parameter of the contract.

function velocore__execute(address, Token[] calldata tokens, int128[] memory r, bytes calldata data) external returns (int128[] memory, int128[] memory) {
  if (data.length > 0) {
    _return_logarithmic_swap();
  }
  uint256 effectiveFee1e9 = fee1e9;
  if (lastWithdrawTimestamp == block.timestamp) {
    unchecked {
      effectiveFee1e9 = (effectiveFee1e9 * feeMultiplier) / 1e9;
    }
  }

  uint256 iLp = type(uint256).max;
  uint256[] memory a = _getPoolBalances(tokens);
  uint256[] memory weights = new uint256[](tokens.length);
  unchecked {
    for (uint256 i = 0; i < tokens.length; ++i) {
      Token token = tokens.uc(i);
      if (token == toToken(this)) {
        weights.u(i, _sumWeight);
        iLp = i;
      } else {
        weights.u(i, _tokenWeight(tokens.uc(i)));
        a.u(i, a.u(i) + 1);
      }
    }
  }
  uint256 invariantMin;
  uint256 invariantMax;
  uint256 k = 1e18;
  bool lpInvolved = iLp != type(uint256).max;
  bool lpUnknown = lpInvolved && (r.u(iLp) == type(int128).max);
  unchecked {
    if (lpInvolved) {
      (, invariantMin, invariantMax) = _invariant();
      if (lpUnknown) {
        // instead of calculating the true value of k, which is a weighted geometric average, we approximate with an arithmetic average.
        // this approximation results in higher k which means lower fee, but k otherwise doesn't matter.
        uint256 kw = 0;
        for (uint256 i = 0; i < tokens.length; ++i) {
          if (r.u(i) == type(int128).max) {
            if (i != iLp) {
              kw += weights.u(i);
            }
            continue;
          }
          uint256 balanceRatio;
          balanceRatio = ((int256(a.u(i)) + r.u(i)).toUint256() * 1e18) / a.u(i);
          k += weights.u(i) * balanceRatio;
        }
        k /= (_sumWeight - kw);
      } else {
        k = (1e18 * (int256(invariantMax) - r.u(iLp)).toUint256()) / invariantMax;
      }
    }
  }
  uint256 requestedGrowth1e18 = 1e18;
  uint256 sumUnknownWeight = 0;
  uint256 sumKnownWeight = 0;
  for (uint256 i = 0; i < tokens.length; i++) {
    if (r.u(i) == type(int128).max) {
      // unknowns
      unchecked {
        if (i != iLp) sumUnknownWeight += weights.u(i);
      }
    } else {
      uint256 tokenGrowth1e18;
      if (i == iLp) {
        unchecked {
          uint256 newInvariant = (int256(invariantMax) - r.u(iLp)).toUint256();
          tokenGrowth1e18 = uint256((1e18 * invariantMin) / newInvariant);
        }
      } else {
        unchecked {
          sumKnownWeight += weights.u(i);
          uint256 b = (int256(a.u(i)) + r.u(i)).toUint256(); // this captures overflow too
          uint256 fee;
          uint256 a_prime = k > 1e18 ? a.u(i) : (k * a.u(i)) / 1e18;
          uint256 b_prime = k > 1e18 ? ((b * 1e18) / k) : b; // fees are not crucial for the integrity of the pool. avoiding ceilDiv to save gas

          if (b_prime > a_prime) {
            fee = ceilDivUnsafe((b_prime - a_prime) * effectiveFee1e9, 1e9);
          }

          tokenGrowth1e18 = (1e18 * (b - fee)) / a.u(i);
        }
      }
      if (tokenGrowth1e18 <= 0.01e18 || tokenGrowth1e18 >= 100e18) {
        _return_logarithmic_swap();
      }
      requestedGrowth1e18 = (requestedGrowth1e18 * rpow(uint256(tokenGrowth1e18), weights.u(i), 1e18)) / 1e18; // less growth == less exit, so round down
      require(tokenGrowth1e18 > 0);
    }
  }

  unchecked {
    uint256 unaccountedFeeAsGrowth1e18 = k >= 1e18 ? 1e18 : rpow(1e18 - ((1e18 - k) * effectiveFee1e9) / 1e9, _sumWeight - sumUnknownWeight - sumKnownWeight, 1e18);
    requestedGrowth1e18 = (requestedGrowth1e18 * unaccountedFeeAsGrowth1e18) / 1e18;
  }

  uint256 g_;
  uint256 g;

  {
    int256 w = int256(sumUnknownWeight);
    unchecked {
      if (lpUnknown) w -= int256(_sumWeight);
    }

    require(w != 0);

    (g_, g) = pow_reciprocal(requestedGrowth1e18, -w);
  }
  for (uint256 i = 0; i < tokens.length; i++) {
    if (r.u(i) != type(int128).max) continue;

    if (i != iLp) {
      uint256 b = Math.ceilDiv(g * a.u(i), 1e18);
      uint256 fee;
      uint256 a_prime = k > 1e18 ? a.u(i) : (k * a.u(i)) / 1e18;
      uint256 b_prime = k > 1e18 ? Math.ceilDiv(uint256(b * 1e18), k) : b;

      if (b_prime > a_prime) {
        unchecked {
          fee = Math.ceilDiv((b_prime - a_prime) * 1e9, 1e9 - effectiveFee1e9) - (b_prime - a_prime);
        }
      }
      r.u(i, ((b + fee).toInt256() - a.u(i).toInt256()).toInt128());
    } else {
      uint256 b = (g_ * invariantMin) / 1e18;
      r.u(i, -(b.toInt256() - invariantMax.toInt256()).toInt128());
    }
  }

  if (iLp != type(uint256).max && r.u(iLp) > 0) {
    _simulateBurn(uint256(int256(r.u(iLp))));
    if (lastWithdrawTimestamp != block.timestamp) {
      feeMultiplier = 1e9;
      lastWithdrawTimestamp = uint32(block.timestamp);
    }
    feeMultiplier = ((feeMultiplier * invariantMax) / (invariantMax - uint256(int256(r.u(iLp))))).toUint128();
  } else if (iLp != type(uint256).max && r.u(iLp) < 0) {
    _simulateMint(uint256(int256(-r.u(iLp))));
  }
  return (new int128[](tokens.length), r);
}

Step 3:

The value of this feeMultiplier index affects the number of tokens exchanged, and it increases whenever a withdrawal occurs and gets reset to 1 after the next block. This factor gets multiplied by the fee rate to calculate the actual fee denominated by the `effectiveFee1e9` parameter. 

uint256 effectiveFee1e9 = fee1e9;
if (lastWithdrawTimestamp == block.timestamp) {
  unchecked {
    effectiveFee1e9 = (effectiveFee1e9 * feeMultiplier) / 1e9;
  }
}

Step 4:

As a result of the manipulation, since the effectiveFee1e9 value within this function was guarded under an unchecked arithmetic operation, and lacked proper input validation, it allowed the attacker to exceed their acceptable boundary range of over 100%, leading to incorrect fee calculations and subsequent logic malfunction.

unchecked {
  uint256 unaccountedFeeAsGrowth1e18 = k >= 1e18 ? 1e18 : rpow(1e18 - ((1e18 - k) * effectiveFee1e9) / 1e9, _sumWeight - sumUnknownWeight - sumKnownWeight, 1e18);
  requestedGrowth1e18 = (requestedGrowth1e18 * unaccountedFeeAsGrowth1e18) / 1e18;
}

Step 5:

Velocore allows users to withdraw multiple tokens from the LP into a single token by applying a fee to the other tokens. When the `effectiveFee1e9` value was greater than 100%, it caused the subsequent calculations to underflow, changing a withdrawal into a large deposit.

The attacker therefore leveraged the manipulated feeMultiplier parameter to call the execute function again through the router contract to drain funds from the LP pool.

Step 6:

The exploiter converted a part of the stolen assets into ETH and then transferred approximately 1,807.38 ETH to the Ethereum Mainnet through a cross-chain bridge over two different transactions. As of this writing, the attacker has already laundered the stolen assets worth 1806.1 ETH, or approximately $6.9 million, to Tornado Cash.

Aftermath#

The team acknowledged the occurrence of the exploit and stated that most of the funds in their LPs were stolen. The incident targeted the CPMM pools, and the other stable pools remain unaffected. They have also shared a detailed post-mortem report of the incident. The team sent an on-chain message to the exploiter proposing to return 90% of the stolen assets to their treasury, allowing the attacker to retain the remaining 10% as a white-hat bug bounty reward.

Linea halted the sequencer, thereby stopping block production between 5081800 and 5081801, which lasted for roughly an hour. This helped to reduce the damage caused by the exploit and prevent the additional funds from bridging out.

Solution#

To address the Velocore exploit, a comprehensive solution needs to be implemented to mitigate the vulnerabilities and ensure robust security measures moving forward. The first critical step is resolving the access control issue. By enforcing strict access controls, it will be ensured that only authorized entities can invoke the sensitive functions of the protocol. This can be achieved by implementing proper authentication mechanisms and role-based access controls within the smart contract. Moreover, introducing a multisignature approval system for critical operations can add an additional layer of security, preventing unauthorized access and manipulation.

Another critical vulnerability was the unchecked arithmetic operations, which resulted in underflows. To resolve this, it is essential to use safe arithmetic libraries, such as OpenZeppelin’s SafeMath, which automatically handle overflows and underflows by reverting transactions that attempt invalid operations. This ensures that all arithmetic calculations are performed within the bounds of acceptable values, preventing the logic errors that allowed the exploit. Furthermore, thorough testing and formal verification techniques should be employed to ensure the mathematical correctness of the contract’s logic. This includes edge case analysis and scenario testing to identify potential weaknesses in the contract’s implementation.

Proper input validation is also paramount in preventing exploits similar to the one experienced by Velocore. Ensuring that all inputs to the smart contract functions are validated against expected ranges and formats can prevent malicious actors from injecting harmful data. This involves the implementation of rigorous input validation checks within the smart contract code, verifying that inputs adhere to predefined constraints and rejecting any inputs that do not meet these criteria. Additionally, adopting standardized coding practices and security frameworks can help in maintaining consistent and secure code quality across the entire protocol.

Despite undergoing three separate audits, the protocol's security partners failed to identify the vulnerabilities that led to the exploit. This highlights the need for continuous and dynamic security assessments beyond static audits. Regularly scheduled audits, coupled with real-time monitoring and automated security tools, can help identify and mitigate vulnerabilities that may be missed during initial reviews.

Even with rigorous security measures in place, vulnerabilities can still be exploited. In these cases, partnering with Neptune Mutual can be incredibly beneficial. By setting up a dedicated cover pool with Neptune Mutual, the adverse effects of incidents such as the Velocore exploit can be greatly reduced. Neptune Mutual excels in providing coverage for losses due to smart contract vulnerabilities, using parametric policies designed specifically for these risks.

Working with Neptune Mutual streamlines the recovery process by reducing the need for extensive proof-of-loss documentation. After an incident is verified and resolved through our detailed incident resolution protocol, we quickly move to provide compensation and financial support to those affected. This ensures that users impacted by security breaches receive prompt assistance.

Our coverage spans several major blockchain networks, including Ethereum, Arbitrum, and the BNB chain, providing extensive support to a variety of Defi users. This broad coverage enhances our ability to protect against a wide range of vulnerabilities, thereby strengthening the overall security for our diverse client base.

Reference Source Beosin  

By

Tags