HTB: Honor Among Thieves

Table of Contents

The Contracts:


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

Getting the key:

Now, looking at Rivals.sol we can see that we need a _key which is bytes32, this means that there’s 2^256 possible keys.

One way to solve this would be brute-forcing, but 2^256 keys would take a loong time to compute, and there’s actually an easier way where we don’t even have to write any contract!

Looking at the talk function, we can see that if someone answers incorrectly, it calls the event emit Voice(block.timestamp % 5);, however, when the key is correct, it calls emit Voice(5);. Of course we can’t see when the correct _key has been used, if no one is the solver yet, so let’s start off by checking that:

cast call --rpc-url $RPC $TARGET "solver()(address)"

Luckily, this returns an address! This means that we can find the correct _key in the blockchain, by looking at the Voice events, and checking which one has the severity of 5.

However, if we run:

cast logs --rpc-url $RPC "event Voice(uint256 indexed severity)" --from-block earliest --to-block latest

There’s a lot of events, and we can’t really see which one is the correct one, so instead of filtering through them all 1-by-1, we can write a script which does it for us:

const {Web3} = require('web3')
const web3 = new Web3(new Web3.providers.HttpProvider('')); 

const contractAddress = '0x5F0c4946395d7729f9eB6598de3930cE5c33EAE9';
const contractABI = [
        "anonymous": false,
        "inputs": [
                "indexed": true,
                "internalType": "uint256",
                "name": "severity",
                "type": "uint256"
        "name": "Voice",
        "type": "event"
        "constant": false,
        "inputs": [
                "name": "_key",
                "type": "bytes32"
        "name": "talk",
        "outputs": [],
        "payable": false,
        "stateMutability": "nonpayable",
        "type": "function"

const contract = new web3.eth.Contract(contractABI, contractAddress);

const findEvent = async () => {
    const blockNumber = await web3.eth.getBlockNumber();

    for (let i = 0; i <= blockNumber; i++) {
        const block = await web3.eth.getBlock(i, true); // Get block with transactions
        if (block && block.transactions) {
            for (let j = 0; j < block.transactions.length; j++) {
                const tx = block.transactions[j];
                const receipt = await web3.eth.getTransactionReceipt(tx.hash);

                if (receipt && receipt.logs) {
                    for (let k = 0; k < receipt.logs.length; k++) {
                        const log = receipt.logs[k];

                        if (log.address === contractAddress.toLowerCase()) {

                            const event =await contract.getPastEvents('Voice', {
                                fromBlock: i,
                                toBlock: i,
                                filter: { severity: '5' } // Filter for severity 5

                            if (event.length > 0) { // If event is found the length is over 0
                                console.log('Found emit Voice(5) in block', i);
                                console.log('Transaction:', tx.hash);
                                console.log('TX Input:', tx.input);

    console.log('emit Voice(5) not found in contract logs.');


The contract above, parses through all the events, and quits when it finds one with a severity of 5.

After this, I just took the output and ran the following command:

cast send --rpc-url $RPC --private-key $PK $TARGET $INPUT

Running the command for who the solver was, I now saw my own key which allowed me to get the flag!