code-423n4 / 2023-08-dopex-findings

3 stars 3 forks source link

purchase of at-the-expiry option is possible creating free option opportunity for 1 week #166

Closed code423n4 closed 1 year ago

code423n4 commented 1 year ago

Lines of code

https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L283-L286

Vulnerability details

Impact

purchase of at-the-expiry option is possible creating free option opportunity for people to bond for 1 week.

purchase is a function for the rpdxV2Core to purchase a perpetual atlantic put which can be settled into the collateral whenever it's price becomes ITM. Premium is calculated based on the timeToExpiry (with ref to blackScholes model).

Here is the workflow:

  1. purchase uses timeToExpiry to calculate the premium needed for buying an option, which is the difference between nextFundingPaymentTimestamp() to the current block timestamp.

  2. nextFundingPaymentTimestamp = genesis + (latestFundingPaymentPointer * fundingDuration), where fundingDuration is a fixed 7 days, genesis is immutably set upon construction.

  3. latestFundingPaymentPointer is updated every time the block.timestamp >= nextFundingPaymentTimestamp(). There is a possibility that block.timestamp == nextFundingPaymentTimestamp()

  4. When such things happen, according to black scholes model, the premium required to purchase a put option with zero time to expiry is 0. Essentially if an attacker can always choose to bond/purchase option at this time, such that the Vault collect no premium for that initial epoch from the V2Core even it keeps writing options.

  5. Although the vault would collect funding from the V2Core according to pre-defined funding rate, however, the payFunding is only callable by V2Core itself, and only triggerable by provideFunding at V2Core which is also an admin function. This control flow means if the admin forget or miss the funding call then the premium would never be collected.

  function purchase(
    uint256 amount,
    address to
  )
    external
    nonReentrant
    onlyRole(RDPXV2CORE_ROLE)
    returns (uint256 premium, uint256 tokenId)
  {
    _whenNotPaused();
    _validate(amount > 0, 2);

    updateFunding();

    uint256 currentPrice = getUnderlyingPrice(); // price of underlying wrt collateralToken
    uint256 strike = roundUp(currentPrice - (currentPrice / 4)); // 25% below the current price
    IPerpetualAtlanticVaultLP perpetualAtlanticVaultLp = IPerpetualAtlanticVaultLP(
        addresses.perpetualAtlanticVaultLP
      );

    // Check if vault has enough collateral to write the options
    uint256 requiredCollateral = (amount * strike) / 1e8;

    _validate(
      requiredCollateral <= perpetualAtlanticVaultLp.totalAvailableCollateral(),
      3
    );

    uint256 timeToExpiry = nextFundingPaymentTimestamp() - block.timestamp;

    // Get total premium for all options being purchased
    premium = calculatePremium(strike, amount, timeToExpiry, 0);
...

purchase can be called by bond -> _purchaseOption at the V2Core

  function bond(
    uint256 _amount,
    uint256 rdpxBondId,
    address _to
  ) public returns (uint256 receiptTokenAmount) {
    _whenNotPaused();
    // Validate amount
    _validate(_amount > 0, 4);

    // Compute the bond cost
    (uint256 rdpxRequired, uint256 wethRequired) = calculateBondCost(
      _amount,
      rdpxBondId
    );

    IERC20WithBurn(weth).safeTransferFrom(
      msg.sender,
      address(this),
      wethRequired
    );

    // update weth reserve
    reserveAsset[reservesIndex["WETH"]].tokenBalance += wethRequired;

    // purchase options
    uint256 premium;
    if (putOptionsRequired) {
      premium = _purchaseOptions(rdpxRequired);
    }
...

_purchaseOption

  function _purchaseOptions(
    uint256 _amount
  ) internal returns (uint256 premium) {
    /**
     * Purchase options and store ERC721 option id
     * Note that the amount of options purchased is the amount of rDPX received
     * from the user to sufficiently collateralize the underlying DpxEth stored in the bond
     **/
    uint256 optionId;

    (premium, optionId) = IPerpetualAtlanticVault(
      addresses.perpetualAtlanticVault
    ).purchase(_amount, address(this));

    optionsOwned[optionId] = true;
    reserveAsset[reservesIndex["WETH"]].tokenBalance -= premium;
  }

Impact: free option purchase + exercise for ITM strikes at the time of expiry possible. Since settle at RdpxV2Core can only be called by admin, thus the attack can only completed if any user called purchase of option through bondWithDelegate and the the admin settle the option within the same block (time == block.timestamp).

privileged call + severe impact = medium risk issue

https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L283

https://github.com/code-423n4/2023-08-dopex/blob/main/contracts/perp-vault/PerpetualAtlanticVault.sol#L463

Proof of Concept

Tools Used

Recommended Mitigation Steps

Consider revoking the ability to purchase at the expiry time.

Assessed type

Invalid Validation

c4-pre-sort commented 1 year ago

bytes032 marked the issue as sufficient quality report

c4-pre-sort commented 1 year ago

bytes032 marked the issue as duplicate of #761

c4-judge commented 12 months ago

GalloDaSballo marked the issue as satisfactory