How Was Seneca Protocol Exploited?

8 min read

Learn how Seneca Protocol was exploited, resulting in a loss of assets worth $6.5 million.

TL;DR#

On February 28, 2024, Seneca Protocol was exploited on the Ethereum Mainnet and Arbitrum Chain due to a smart contract vulnerability, which resulted in a loss of 1,900 ETH, worth approximately $6.5 million.

Introduction to Seneca#

Seneca is an omnichain CDP protocol for yield-bearing assets.

Vulnerability Assessment#

The root cause of the exploit is due to an arbitrary external call vulnerability.

Incident Analysis#

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

The vulnerable contracts have a `_call` function, which decodes input data to extract the callee address, the call data, and flags indicating how additional passed values should be appended to the call data. It then performs a call to the specified address with the assembled call data.

/// @dev Helper function to perform a contract call and eventually extracting revert messages on failure.
/// Calls to `bentoBox` are not allowed for obvious security reasons.
/// This also means that calls made from this contract shall *not* be trusted.
function _call(uint256 value, bytes memory data, uint256 value1, uint256 value2) internal whenNotPaused returns (bytes memory, uint8) {
  (address callee, bytes memory callData, bool useValue1, bool useValue2, uint8 returnValues) = abi.decode(data, (address, bytes, bool, bool, uint8));

  if (useValue1 && !useValue2) {
    callData = abi.encodePacked(callData, value1);
  } else if (!useValue1 && useValue2) {
    callData = abi.encodePacked(callData, value2);
  } else if (useValue1 && useValue2) {
    callData = abi.encodePacked(callData, value1, value2);
  }

  require(!blacklisted[callee], "Chamber: can't call");

  (bool success, bytes memory returnData) = callee.call{value: value}(callData);
  require(success, "Chamber: call failed");
  return (returnData, returnValues);
}

The vulnerability stems from the fact that this function does not sufficiently restrict the types of calls that can be made, nor does it perform adequate checks on the callee address beyond a basic blacklist check.

This means that the function allows calls to any address not explicitly blacklisted and performs no validation on the call data itself, which means malicious or erroneous data could be executed if it passes the blacklist check.

The externally callable `performOperations` function within the contract uses a bunch of conditional statements to determine the next course of action to be taken.

/// @notice Executes a set of actions and allows composability (contract calls) to other contracts.
/// @param actions An array with a sequence of actions to execute (see OPERATION_ declarations).
/// @param values A one-to-one mapped array to `actions`. ETH amounts to send along with the actions.
/// Only applicable to `OPERATION`, `OPERATION_BENTO_DEPOSIT`.
/// @param datas A one-to-one mapped array to `operations`. Contains abi encoded data of function arguments.
/// @return value1 May contain the first positioned return value of the last executed action (if applicable).
/// @return value2 May contain the second positioned return value of the last executed action which returns 2 values (if applicable).
function performOperations(uint8[] calldata actions, uint256[] calldata values, bytes[] calldata datas) external payable whenNotPaused returns (uint256 value1, uint256 value2) {
  OperationStatus memory status;
  uint256 actionsLength = actions.length;
  for (uint256 i = 0; i < actionsLength; i++) {
    uint8 action = actions[i];
    if (!status.hasAccrued && action < 10) {
      accumulate();
      status.hasAccrued = true;
    }
    if (action == Constants.OPERATION_ADD_COLLATERAL) {
      (int256 share, address to, bool skim) = abi.decode(datas[i], (int256, address, bool));
      depositCollateral(to, skim, _num(share, value1, value2));
    } else if (action == Constants.OPERATION_REPAY) {
      (int256 part, address to, bool skim) = abi.decode(datas[i], (int256, address, bool));
      _repay(to, skim, _num(part, value1, value2));
    } else if (action == Constants.OPERATION_REMOVE_COLLATERAL) {
      (int256 share, address to) = abi.decode(datas[i], (int256, address));
      _withdrawCollateral(to, _num(share, value1, value2));
      status.needsSolvencyCheck = true;
    } else if (action == Constants.OPERATION_BORROW) {
      (int256 amount, address to) = abi.decode(datas[i], (int256, address));
      (value1, value2) = _borrow(to, _num(amount, value1, value2));
      status.needsSolvencyCheck = true;
    } else if (action == Constants.OPERATION_UPDATE_PRICE) {
      (bool must_update, uint256 minRate, uint256 maxRate) = abi.decode(datas[i], (bool, uint256, uint256));
      (bool updated, uint256 rate) = updatePrice();
      require((!must_update || updated) && rate > minRate && (maxRate == 0 || rate < maxRate), "Chamber: rate not ok");
    } else if (action == Constants.OPERATION_BENTO_SETAPPROVAL) {
      (address user, address _masterContract, bool approved, uint8 v, bytes32 r, bytes32 s) = abi.decode(datas[i], (address, address, bool, uint8, bytes32, bytes32));
      bentoBox.setMasterContractApproval(user, _masterContract, approved, v, r, s);
    } else if (action == Constants.OPERATION_BENTO_DEPOSIT) {
      (value1, value2) = _bentoDeposit(datas[i], values[i], value1, value2);
    } else if (action == Constants.OPERATION_BENTO_WITHDRAW) {
      (value1, value2) = _bentoWithdraw(datas[i], value1, value2);
    } else if (action == Constants.OPERATION_BENTO_TRANSFER) {
      (IERC20 token, address to, int256 share) = abi.decode(datas[i], (IERC20, address, int256));
      bentoBox.transfer(token, msg.sender, to, _num(share, value1, value2));
    } else if (action == Constants.OPERATION_BENTO_TRANSFER_MULTIPLE) {
      (IERC20 token, address[] memory tos, uint256[] memory shares) = abi.decode(datas[i], (IERC20, address[], uint256[]));
      bentoBox.transferMultiple(token, msg.sender, tos, shares);
    } else if (action == Constants.OPERATION_CALL) {
      (bytes memory returnData, uint8 returnValues) = _call(values[i], datas[i], value1, value2);

      if (returnValues == 1) {
        (value1) = abi.decode(returnData, (uint256));
      } else if (returnValues == 2) {
        (value1, value2) = abi.decode(returnData, (uint256, uint256));
      }
    } else if (action == Constants.OPERATION_GET_REPAY_SHARE) {
      int256 part = abi.decode(datas[i], (int256));
      value1 = bentoBox.toShare(senUSD, totalBorrow.toElastic(_num(part, value1, value2), true), true);
    } else if (action == Constants.OPERATION_GET_REPAY_PART) {
      int256 amount = abi.decode(datas[i], (int256));
      value1 = totalBorrow.toBase(_num(amount, value1, value2), false);
    } else if (action == Constants.OPERATION_LIQUIDATE) {
      _operationLiquidate(datas[i]);
    } else {
      (bytes memory returnData, uint8 returnValues, OperationStatus memory returnStatus) = _extraOperation(action, status, values[i], datas[i], value1, value2);
      status = returnStatus;

      if (returnValues == 1) {
        (value1) = abi.decode(returnData, (uint256));
      } else if (returnValues == 2) {
        (value1, value2) = abi.decode(returnData, (uint256, uint256));
      }
    }
  }

  if (status.needsSolvencyCheck) {
    (, uint256 _exchangeRate) = updatePrice();
    require(_isSolvent(msg.sender, _exchangeRate), "Chamber: user insolvent");
  }
}

The exploiter meticulously crafted the call data that triggered the `action == Constants.OPERATION_CALL` case, allowing them to call any contract with arbitrary data.

The attacker then used constructed calldata parameters to call the transferfrom function in order to transfer tokens that were approved by the users to the contract, thereby luring away these assets to their address. 

The contract also made use of `Pausable` functionality by OpenZeppelin. This library only defines pause and unpause internally, and thus they need to be exposed to an external or public function governed by access control in order to be used.

// SPDX-License-Identifier: MIT
// Chamber
pragma solidity >=0.8.0;

import "@openzeppelin/contracts/access/Ownable2Step.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "../contracts/interfaces/IMasterContract.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "../contracts/libraries/BoringRebase.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "../contracts/interfaces/IOracle.sol";
import "../contracts/interfaces/ISwapperV2.sol";
import "../contracts/interfaces/IBentoBoxV1.sol";
import "./Constants.sol";

contract Chamber is Ownable2Step, IMasterContract, Pausable {
  using SafeMath for uint256;
  using SafeMath for uint128;
  using SafeCast for uint256;
  using SafeCast for uint128;
  using RebaseLibrary for Rebase;
  using SafeERC20 for IERC20;

  // .....
}

However, in their `Pausable` library implementation, the associated `_pause` and `_unpause` functions were set to internal visibility specifiers. This means that even though it inherited the library features, these functionalities had no options for invocation, and thus the associated `whenNotPaused` features were not usable.

/**
 * @dev Triggers stopped state.
 *
 * Requirements:
 *
 * - The contract must not be paused.
 */
function _pause() internal virtual whenNotPaused {
  _paused = true;
  emit Paused(_msgSender());
}

/**
 * @dev Returns to normal state.
 *
 * Requirements:
 *
 * - The contract must be paused.
 */
function _unpause() internal virtual whenPaused {
  _paused = false;
  emit Unpaused(_msgSender());
}

Aftermath#

The team acknowledged the occurrence of the exploit and stated that they were reaching out to third parties for any additional support. It has been known that the team had employed a blockchain security company to conduct an audit of their protocol.

The team sent an on-chain message to the exploiter with hopes of recovering a portion of the exploited funds. They are proposing a bounty reward of 20% for the exploiter as per their whitehat efforts. At the time of this writing, on-chain data further reveals that the exploiter has sent funds worth approximately 1,536 ETH totaling $5,358,817 to the wallet address mentioned by the Seneca Deployer in their earlier communication.

It is reported that an X (formerly Twitter) user warned the team about the exact same issue on November 15, 2023, but was supposedly blocked by the team and denied any attention to detail on the issue.

Solution#

To mitigate and prevent similar exploits in the future, such as the one that impacted the Seneca Protocol, a comprehensive approach that encompasses both developer practices and user awareness is essential. Developers must prioritize conducting thorough audits through reputable audit firms before deploying smart contracts. These audits play a crucial role in identifying potential security vulnerabilities and weaknesses within the contract's code that might not be evident at first glance. Furthermore, the practice of rigorous input validation cannot be overstated. By sanitizing all inputs to the contract to ensure they meet the expected type, format, and range, developers can prevent attackers from exploiting vulnerabilities stemming from improperly validated inputs.

From the perspective of users, it becomes crucial to revoke approvals to affected contracts immediately upon discovery of such exploits. Regularly reviewing and managing the token approvals granted to various third-party applications or protocols can significantly reduce the risk of asset misuse in the event of a contract compromise. This practice underscores the importance of understanding and managing the permissions users grant to smart contracts.

On the technical front, the root cause of many exploits often lies in the indiscriminate use of the call method, a low-level function in Ethereum that, while flexible, comes with numerous risks. To enhance security, it's advisable for developers to migrate towards higher-level functions like transfer or send, which include built-in protection mechanisms against a variety of vulnerabilities.

Despite the implementation of rigorous security measures, the potential for vulnerabilities to be exploited cannot be entirely eliminated. In such instances, the role of Neptune Mutual becomes crucial. By partnering with Neptune Mutual to establish a dedicated coverage pool, projects can significantly mitigate the adverse effects of incidents akin to those seen in the DeFi space. Neptune Mutual specializes in offering coverage against losses resulting from smart contract vulnerabilities, employing parametric policies designed specifically to address these unique risks.

Engaging with Neptune Mutual not only simplifies the recovery process for users but also ensures a streamlined approach to compensation without the need for exhaustive documentation of losses. Following the confirmation and resolution of an incident within our robust incident resolution framework, our emphasis shifts towards providing swift financial support to those affected, facilitating rapid recovery from security breaches.

Our services span multiple leading blockchain platforms, including EthereumArbitrum, and the BNB chain, enabling us to serve a broad spectrum of DeFi participants. Through our extensive marketplace, we offer protective measures against a wide array of vulnerabilities, thereby bolstering the security framework for our diverse user base and instilling a greater sense of confidence within the ecosystem.

Reference Source BlockSec

By

Tags