[CSSCTF] Blockchain Challenges Writeup#
Author: k0z1l
Category: Blockchain (Solidity / EVM)
Flag Format: CSSCTF{...}
1. Gateway (Part 1)#
This document covers two CSS CTF blockchain challenges (each maintaining dedicated numbered sections):
- Part 1 — Gateway (port
31337) — detailed below.- Part 2 — Lottery (port
31338) — located in the second half of this document, beginning at# CSS CTF — Blockchain: **Lottery** (Part 2).
Flag:
CSSCTF{CSS{B451C_BL0CKCH41N_5K1LL5}}
(nc 34.116.80.78 31337-> action3returnsCSS{B451C_BL0CKCH41N_5K1LL5})
| Parameter | Value |
|---|---|
| Category | Blockchain (Solidity / EVM) |
| Files | Gate.sol, Setup.sol |
| Chain | Anvil (Foundry) private chain, chainId = 1, each EOA prefunded with 5000 ETH |
| Endpoint | nc 34.116.80.78 31337 — menu 1/2/3, ticket = team name (case-sensitive) |
| Core Techniques | tx.origin vs msg.sender, receive(), storage layout and packing, “private” visibility vs data secrecy, keccak256(abi.encodePacked(...)) |
1.1. Challenge Description & Contracts#
“The reboot has awakened an abandoned UPDC checkpoint guarding access to the Quantum Nexus Network. Its emergency gate still demands three proofs of clearance, but the officers who issued them vanished during The Severance. Find your way through all three doors and claim the credentials left inside. The ticket is your team name, case-sensitive.”
The narrative regarding the UPDC checkpoint, Quantum Nexus, and The Severance provides background lore. The core mechanics reside strictly in the three doors defined in the contract.
1.1.1. Gate.sol#
| |
1.1.2. Setup.sol#
| |
Setup is a standard factory and verifier: it deploys Gate (forwarding msg.value) and exposes isSolved(). To capture the flag, isSolved() must return true, meaning gate.solved == true.
1.2. Background Fundamentals#
1.2.1. tx.origin vs msg.sender#
msg.sender: The immediate caller of the current execution frame (an EOA or a contract).tx.origin: The original EOA that signed and initiated the transaction. Across all nested execution frames, it remains constant.
In a call chain EOA -> A -> B:
| Variable | At Contract A | At Contract B |
|---|---|---|
msg.sender | EOA address | Address of A |
tx.origin | EOA address | EOA address |
- If an EOA calls
gate.enter()directly:msg.sender == tx.origin == EOA-> fails. - If an EOA invokes an exploit contract
A.pwn(), which then callsgate.enter():msg.sender = A,tx.origin = EOA-> passes (tx.origin != msg.sender).
This pattern is frequently used as a naive anti-bot or contract check, but is easily bypassed through contract indirection. Similar caller confusion historically enabled tx.origin phishing attacks.
1.2.2. receive() / fallback()#
receive()executes when a contract receives plain Ether with empty calldata (viaaddress(g).call{value: x}("")or standard transfer).- In this contract,
receive()does not restrictmsg.senderortx.origin; any EOA or contract can invoke it. It only assertsstepped == trueandmsg.value > 0. - To transfer Ether from inside a contract, use
address(g).call{value: v}("")withrequire(ok)rather thantransfer(). The legacytransfer()primitive forwards a fixed 2,300 gas stipend, which reverts when the recipient executes storage operations. InGate.sol,receive()executes arequirecheck and updates a storage slot, requiring adequate gas.
1.2.3. EVM Storage Layout — “private” Is Not Confidential#
- Every contract possesses a flat storage array consisting of 32-byte slots indexed from 0.
- Statically sized variables (
uintN,address,bool,bytesN) are packed sequentially into slots when adjacent items fit within 32 bytes. Values are aligned starting from the lowest-order byte (offset 0). privateandinternalare Solidity-level visibility qualifiers: they prevent other contracts from reading the variable via ABI calls, but provide zero cryptographic confidentiality. Any actor can inspect raw storage viaeth_getStorageAt. Therefore,bytes32 private passwordis completely public.
In this challenge, password is accessible via two vectors: reading slot 1 via RPC, or computing it deterministically offline from the constant keccak256("gateway to the flag").
Note on Storage Layout: The inline comments in
Gate.solindicate incorrect slot numbers (see Section 4.3).
1.2.4. keccak256(abi.encodePacked(x)) vs keccak256(abi.encode(x))#
abi.encodePacked("gateway to the flag")concatenates raw UTF-8 bytes without padding or length prefixes:keccak256(abi.encodePacked("gateway to the flag")) = 0x90cd83d75da724f03cbd4c1bd73dbfca4325ab5c4930082484b6f6aa9234d70babi.encode("gateway to the flag")adds an offset and 32-byte length prefix (96 bytes total), resulting in0xc029662a...which is incorrect.
Python equivalent:
| |
1.2.5. Execution Environment#
The target runs on Anvil (web3_clientVersion -> anvil/v1.8.3): transactions auto-mine immediately without block wait times. Each instance provides a dedicated EOA funded with 5,000 ETH, making gas limits negligible. Instances auto-terminate after 30 minutes.
1.3. Analysis: The Three Doors and Solutions#
| Door | Function | Requirement | Solution |
|---|---|---|---|
| 1 | enter() | tx.origin != msg.sender | Call via an intermediary attacker contract |
| 2 | receive() | stepped == true and msg.value > 0 | Transfer at least 1 wei to the Gate address |
| 3 | claim(bytes32) | stepped && funded && _password == password | Supply keccak256("gateway to the flag") |
Key Takeaway 1: Only Door 1 requires execution through an intermediary contract. receive() and claim() enforce no restrictions on msg.sender or tx.origin. The minimal solve requires an intermediary contract only for Door 1, while Doors 2 and 3 can be satisfied directly by an EOA.
Key Takeaway 2: A strict execution sequence is required: enter() -> send Ether -> claim(). Sending Ether before Door 1 reverts because stepped == false.
Key Takeaway 3: password requires no cryptanalysis: a hardcoded string stored in a private slot is fully exposed.
1.4. Practical Reconnaissance and Verification#
1.4.1. Instance Allocation#
| |
The automation script gate_launch.py (pure sockets) manages the workflow: if the server reports An instance is already running!, it selects action 2 (kill) before selecting 1 (launch), then extracts uuid, rpc, private key, and setup via regex into /tmp/gate_cfg.json.
1.4.2. Inspecting On-Chain State#
| |
The on-chain deployment matches the source code, and the password matches our offline computation.
1.4.3. Storage Layout Trap: Misleading Source Comments#
Gate.sol includes the following comments:
| |
However, Solidity packs contiguous smaller types into a single 32-byte slot. Querying the compiler directly confirms this behavior:
| |
| |
All three variables (stepped, funded, and solved) are packed together in slot 2 at byte offsets 0, 1, and 2. Slots 3 and 4 remain completely empty (0x0).
On-chain state evolution during solving:
| |
Relying on the source comments and inspecting eth_getStorageAt(gate, 4) to check solved will always return 0x0, falsely suggesting the exploit failed. The proper verification is either masking slot 2 via (w >> 16) & 1 or invoking Setup.isSolved().
1.5. Exploitation Approaches#
1.5.1. Path A — Dedicated GateEnter Attack Contract#
GateAttack.sol:
| |
Why Door 1 passes: Inside pwn(), execution runs inside GateAttack. When invoking gate.enter(), msg.sender = GateAttack, while tx.origin = EOA. Because tx.origin != msg.sender, the check passes.
Compile:
| |
Deploy and execute (gate_solve.py, using web3.py v8):
| |
Execution output:
| |
1.5.2. Path B — Minimal Approach (Intermediary for Door 1 Only)#
GateEnter.sol:
| |
Execution steps:
- Call
GateEnter.open(gate)from the EOA -> inenter(),msg.sender = GateEnter-> clears Door 1. - EOA transfers 1 wei directly to Gate (
{"to": gate, "value": 1}) ->receive()triggers, settingfunded = true(no contract required). - EOA invokes
gate.claim(pw)-> setssolved = true.
Verified on a newly deployed clean Setup contract:
| |
Both paths succeed, demonstrating that only Door 1 actually restricts direct EOA calls.
1.6. Reproduction Commands & Flag Capture#
With isSolved() == true:
| |
Submitted Flag:
| |
1.6.1. Pitfalls & Common Errors#
Challenge Traps:
- Inaccurate storage comments in
Gate.sol:fundedandsolvedreside in slot 2 alongsidestepped(0x01->0x0101->0x010101). Reading slot 4 causes false negatives. - Lore and narrative: The “UPDC checkpoint” story is flavor text; contract logic is confined to the three defined functions.
- Over-engineering: Door 1 suggests that the entire interaction requires a smart contract, whereas only Door 1 enforces this constraint.
privatevariable confusion: Thepasswordvariable is treated as secret despite being deterministically computable and visible in slot 1 via RPC.
Core Smart Contract Security Lessons:
- Never store secrets, seeds, or validation credentials as plaintext or simple hashes on-chain; all storage is public. Confidentiality requires commit-reveal schemes, zero-knowledge proofs, or off-chain signatures.
tx.originis never a valid human-authentication check; always validate viamsg.sender.privatein Solidity merely disables compiler-generated ABI getters; it provides no data encryption.- Always verify storage layouts via compiler artifacts (
storageLayout) rather than trusting source comments. - Inspect each
requirecondition individually to determine where caller constraints are actually enforced.
Scripting Gotchas Encountered:
web3.py v8:HexBytes.hex()omits the0xprefix; avoid mismatched string prefix comparisons.build_transaction()requires{"from": ...}for gas estimation.- Manually constructed transactions (bypassing
build_transaction) must supply explicit fee parameters (maxFeePerGas/maxPriorityFeePerGasor legacygasPrice). gate.functions.stepped()does not exist because private variables lack getters; read raw storage instead.solcjsCLI lacks--storage-layout; use--standard-jsonwithoutputSelection.- Instances are limited to one per ticket; terminate running instances before spawning new ones.
- Instances auto-expire after 30 minutes.
1.6.2. Appendix — Reproduction Files#
| |
| |
Summary: The password is publicly readable in storage; tx.origin != msg.sender is bypassed via an intermediary contract; receive() accepts 1 wei without caller restrictions; and misleading storage layout comments are debunked using compiler layout analysis.
2. Lottery (Part 2)#
Flag:
CSSCTF{CSS{U5E_4_R4ND0M_FUNCT10N}}
(nc 34.116.80.78 31338-> action3returnsCSS{U5E_4_R4ND0M_FUNCT10N})
| Parameter | Value |
|---|---|
| Category | Blockchain (Solidity / EVM) |
| Files | Lottery.sol, Setup.sol (distributed as Setup(1).sol) |
| Chain | Anvil v1.8.3, chainId = 1, each EOA prefunded with 5000 ETH |
| Endpoint | nc 34.116.80.78 31338 — menu 1/2/3, ticket = team name (case-sensitive) |
| Win Condition | Setup.isSolved() == true, indicating lottery.winner() != address(0) |
| Core Technique | Pseudo-randomness derived from block variables (blockhash, block.timestamp, block.difficulty/prevrandao) can be computed deterministically within the same transaction via an attacker contract |
| Brute-Force Probability | $(1/100)^{10} = 10^{-20}$ — mathematically infeasible; deterministic prediction is required |
2.1. Challenge Description & Contracts#
“Beyond the checkpoint, a Beltway Bandits gambling terminal has resumed broadcasting: Ten wins in a row. One fortune. No second chances. Once used to distribute stolen credits, it now holds an access key to the Bandits’ hidden network. Beat the house and claim it before they return to collect. The ticket is your team name, case-sensitive.”
The objective is simple: win the lottery 10 consecutive times.
2.1.1. Lottery.sol#
| |
2.1.2. Setup.sol#
| |
There are no require() assertions, no tx.origin checks, and no hidden storage slots. The challenge depends entirely on predicting random() % 100 ten consecutive times.
Analysis of the three pseudo-random components:
| Component | EVM Semantics |
|---|---|
blockhash(block.number - 1) | Hash of the immediately preceding block (valid for the 256 most recent blocks). |
block.timestamp | Timestamp of the currently executing block. |
block.difficulty | Post-Paris (The Merge), opcode DIFFICULTY returns PREVRANDAO, providing the mixHash of the current block. |
All three values are invariant for all calls executed within the same block. This forms the core vulnerability.
2.2. Core Principles & EVM Randomness Mechanics#
2.2.1. Block-Derived Randomness Is Deterministic to Smart Contracts#
In a transaction:
| |
All input variables are fixed as soon as the block is created. If an attacker contract computes the identical expression within the same transaction, it receives the exact value that guess() will evaluate against.
2.2.2. Why EOAs Cannot Precompute the Target#
When signing a transaction from an EOA, transaction fields must be committed before the block including it is mined. The upcoming block’s timestamp and prevrandao are unknown in advance. Furthermore, under Anvil’s automine mechanism (1 transaction per block), each new transaction is mined into a new block where blockhash(n-1), block.timestamp, and prevrandao differ from the preceding block.
This was empirically verified (Section 5): computing a guess from head block data and sending it via EOA predicted 79, but the mined block target evaluated to 74, resulting in failure and resetting the streak to 0.
Critical Takeaway: The “10 wins in a row” requirement restricts only EOAs. A smart contract can invoke guess() 10 times within a single transaction. Because all 10 calls share the exact same block state, random() evaluates to the identical value, achieving a 10/10 streak.
2.2.3. Mechanics of abi.encodePacked(a, b, c)#
encodePacked concatenates raw byte arrays without padding. With bytes32 for blockhash, 32 bytes for block.timestamp, and 32 bytes for block.difficulty, the result is a 96-byte payload: parentHash || ts || prevrandao.
| |
Note on Offline Verification: To verify offline, extract the mined block: set
parent_hashto the hash of blockn - 1,tsto blockn’s timestamp, andprevrandaoto blockn’smixHash. Do not use the RPCdifficultyfield (see Section 2.4).
2.2.4. block.difficulty on EVM Differs from RPC difficulty#
Following The Merge, EVM opcode DIFFICULTY (0x44) maps to PREVRANDAO. Compilers for Paris or newer emit the following notice:
| |
Measured on-chain via Probe.sol (eth_call):
| |
The value perceived by the EVM is the block’s mixHash (prevrandao). The difficulty field returned by eth_getBlockByNumber is legacy metadata (0), which yields incorrect results if used for offline calculations (68 vs actual target 74).
2.2.5. Ten Consecutive Guesses in a Single Transaction#
| |
All 10 message calls execute within the same transaction and same block. Consequently, random() remains constant across all 10 calls (target remains identical, e.g., 65). Only a single calculation is necessary.
2.3. Instance Reconnaissance#
| |
Reconnaissance steps:
- Parse
rpc,private key, andsetupprogrammatically intolot_cfg.json. - Query
eth_chainIdandsetup.lottery()to obtain the target address. - Verify contract state:
| |
2.4. Exploitation — Path A: Internal Keccak Recomputation#
Attack.sol:
| |
Design details:
roll()is markedview: it performs no state modifications and reads strictly block context variables.- Type definitions in
abi.encodePackedmatch the target contract:bytes32, uint256, uint256. - The modulo operation
% 100matchestarget = random() % 100. - Because
Lottery.guess()is invoked via regular message calls,msg.senderinLotteryis the address ofAttack. Oncestreaks[Attack] >= 10,winneris set toAttack, satisfyingwinner != address(0)and makingSetup.isSolved() == true.
Compile:
| |
Deploy and execute:
| |
2.5. Exploitation — Path B: Deploying Attack Contract via Python#
Because random() is declared public view, an attacker contract can call the target contract directly to obtain the target number:
| |
Because lot.random() evaluates within the same transaction, it returns the exact value that guess() evaluates against.
2.5.1. Empirical Proof of EOA Infeasibility#
Attempting to predict targets from an EOA off-chain:
| |
Observed block timestamp deltas in this instance were non-deterministic: +5s, +3s, +3s, +7s. Predicting block timestamps from off-chain is practically infeasible, confirming that EOA-based precomputation is impossible.
2.6. Execution Results#
Execution log on instance f3ff3e06-...:
| |
2.5. Exploitation — Path B: Deploying Attack Contract via Python#
Submitted Flag:
| |
2.7. Reproduction Scripts and Instructions#
| |
| |
2.8. Pitfalls, Common Errors, and Lessons Learned#
Challenge Traps:
- “Ten wins in a row” misconception: Implies guessing 10 different random numbers; in practice, all 10 calls within a single transaction evaluate against the exact same target.
random()declaredpublic view: Acts as an on-chain oracle for attacker contracts.block.difficultysemantics: On post-Merge EVM chains, this opcode reflectsprevrandao(mixHash). Using the RPCdifficultyfield for offline calculations yields incorrect numbers.blockhash(block.number - 1): Evaluates to the parent block’s hash, not the current block’s hash.
Technical Scripting Gotchas:
web3.py v8: PassinggasPricetobuild_transaction()when EIP-1559 fields are expected raisesTypeError: Unknown kwargs: ['gasPrice'].- Event decoding requires the explicit ABI definition of
Guessedfor receipt processing. - Always check
receipt.status == 1. - Compiling complex multi-file projects with
solcjsdirectly can result in import path resolution errors; manual ABI extraction for required interfaces is often cleaner.
Security Recommendations for On-Chain Randomness:
- Never rely on block context variables (
blockhash,timestamp,prevrandao,block.number) as entropy sources for security-sensitive logic. All block attributes are transparent and constant across all execution frames within that block. - Secure on-chain randomness requires commit-reveal mechanisms, Chainlink VRF, or off-chain cryptographic proofs. If using block hashes, enforce a multi-block commit-and-resolve separation ($k \ge 2$).
- For solvers: An N-streak requirement executed in a single transaction reduces to a single calculation looped N times.
Summary: The random() function is deterministic because it relies on blockhash(n-1) || timestamp || prevrandao, which are invariant throughout a transaction. An attacker contract calling guess() 10 times in a single transaction reliably wins 10/10 rounds and registers as the winner.
