• YouTube Channel
  • System Status
  • VS Code Extension
  • Reused msg.value

    Overview

    What is the Reused msg.value vulnerability?

    In any Ethereum transaction, the value of msg.value is a constant, representing the amount of ether sent with the transaction. If this variable is used in a loop with an expectation that it will be applied each time the loop runs, the code may be vulnerable to exploit by an attacker who can effectively reuse the same ether payment repeatedly.

    A real-world example: the Opyn ETH Put options

    In August 2020, certain options instruments created via Opyn's Convexity Protocol denominated in Ether were found to be vulnerable to an exploit that allowed an attacker to redeem ether a single time in exchange for multiple token redemptions, due to the redemption amount (in ether, as measured by msg.value) being used in a loop, and thus invariant during all of the looped checks. Specifically, the contract contained an external exercise() function as follows:

      function exercise(
        uint256 oTokensToExercise,
        address payable[] memory vaultsToExerciseFrom
      ) public payable {
        for (uint256 i = 0; i < vaultsToExerciseFrom.length; i++) {
          address payable vaultOwner = vaultsToExerciseFrom[i];
          require(
              hasVault(vaultOwner),
              "Cannot exercise from a vault that doesn't exist"
          );
          Vault storage vault = vaults[vaultOwner];
          if (oTokensToExercise == 0) {
              return;
          } else if (vault.oTokensIssued >= oTokensToExercise) {
              _exercise(oTokensToExercise, vaultOwner);
              return;
          } else {
              oTokensToExercise = oTokensToExercise.sub(vault.oTokensIssued);
              _exercise(vault.oTokensIssued, vaultOwner);
          }
        }
        require(
          oTokensToExercise == 0,
          "Specified vaults have insufficient collateral"
        );
      }
    

    Note the for loop over the vaults, and that each iteration of the loop may call the internal _exercise() function, which is reproduced in part below:

      function _exercise(
        uint256 oTokensToExercise,
        address payable vaultToExerciseFrom
      ) internal {
    
        // additional contract logic elided for brevity
    
        // 4. Transfer in underlying, burn oTokens + pay out collateral
        // 4.1 Transfer in underlying
        if (isETH(underlying)) {
          require(msg.value == amtUnderlyingToPay, "Incorrect msg.value");
        } else {
          require(
              underlying.transferFrom(
                  msg.sender,
                  address(this),
                  amtUnderlyingToPay
              ),
              "Could not transfer in tokens"
          );
        }
    
        // 4.2 burn oTokens
        _burn(msg.sender, oTokensToExercise);
    
        // 4.3 Pay out collateral
        transferCollateral(msg.sender, amtCollateralToPay);
    
        // additional contract logic elided for brevity
    
      }
    
    

    On line 11 of the partially reproduced _exercise() function, note that the required payment for the underlying (as denominated in Ether) is checked via the msg.value property, implying a payment to the smart contract. However, because this function is called in a loop at the level of the exercise() function, the same check against msg.value will succeed for each iteration of the loop (if it succeeds at all), allowing the same amount of ether to be redeemed multiple times.

    (Code examples are drawn from GitHub; see line 425 of the contract for the definition of the exercise() function, and line 750 for the definition of the _exercise() function that is called inside the loop.)

    In this case, the vulnerability was discovered by white hat hackers before attackers could exploit all of the funds at risk, but over 370,000 USDC was lost to attacks.

    Further reading: Opyn ETH Put Exploit Post Mortem

    Technical example of vulnerable code

      // SPDX-License-Identifier: Unlicense
      pragma solidity ^0.8.0;
    
      contract NFTMint {
          mapping (uint256 => address) nftOwners;
          uint256 nextTokenId = 0;
    
          function buyMultipleNFTs(uint256 numNFTs) external payable {
              for (uint i = 0; i < numNFTs; i++) {
                  buyOneNFT(msg.sender);
              }
          }
    
          function buyOneNFT(address buyer) internal payable {
              if (msg.value < 1 ether) {
                revert("Insufficient payment");
              }
              nftOwners[nextTokenId] = buyer;
              nextTokenId++;
          }
      }
    
    

    In the above example, the contract NFTMint intends to charge buyers one ether per NFT, with a function allowing a buyer to purchase several in a batch. Because the payment is made in Ether via msg.value, it is possible for an attacker to purchase multiple NFTs for the price of one, as the message value from the perspective of a call to buyMultipleNFTs() is invariant.

    Technical example of how to fix the vulnerability

      // SPDX-License-Identifier: Unlicense
      pragma solidity ^0.8.0;
    
      contract NFTMint {
          mapping (uint256 => address) nftOwners;
          uint256 nextTokenId = 0;
    
          function buyMultipleNFTs(uint256 numNFTs) external payable {
              uint256 totalPayment = msg.value; // track the total payment in a variable
    
              for (uint i = 0; i < numNFTs; i++) {
                  buyOneNFT(msg.sender, totalPayment); // pass the total payment to the internal function
                  totalPayment -= 1 ether; // subtract the cost of one NFT from the total payment
              }
          }
    
          function buyOneNFT(address buyer, uint256 totalPayment) internal payable {
              if (totalPayment < 1 ether) {
                revert("Insufficient payment");
              }
              nftOwners[nextTokenId] = buyer;
              nextTokenId++;
          }
      }
    
    

    In the corrected code example, the payment for multiple NFTs is now being tracked in a variable that gets updated as it is passed between the buyMultipleNFTs() and buyOneNFT() functions. The internal function now checks the dynamic total payment value (updated in the caller), rather than the invariant msg.value variable, to ensure that the buyer has paid for each individual NFT.