Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
BlockchainMedium

Byte Double Cross

CTF writeup for Byte Double Cross from niteCTF

//Byte Double Cross

>TL;DR

  • Pulled the Sepolia bytecode at 0x1d7E03675b15a6602A14Ff6321A2cc2ea16CF53C via public RPC.
  • Decompiled the contract to learn that selector 0xb8da5144 verifies an array of eight hashed "chunks".
  • Read storage slot 0 (length), slot 1 (owner) and the eight bytes32 hashes from the dynamic array base keccak256(0x00).
  • Noticed each chunk is validated as keccak256(abi.encodePacked(chunk, owner)), so you only need to brute-force short strings whose hash matches the stored value.
  • Ran a short Python brute-forcer to recover every chunk and assemble the flag: nite{Pr1v8_v4r$_4r3_n07_pRiv473}.

>1. Recon Phase

Network / Endpoints

  • Chain: Sepolia
  • RPC: https://ethereum-sepolia-rpc.publicnode.com
  • Target contract: 0x1d7E03675b15a6602A14Ff6321A2cc2ea16CF53C

Bytecode + High-Level Structure

  1. Pulled bytecode with eth_getCode.
  2. Fed it to EtherVM to get a readable pseudo-Solidity view.
  3. Dispatch table showed three public selectors:
    • 0x8da5cb5bowner()
    • 0xb8da5144 → unknown puzzle entrypoint #1
    • 0xc91d4ca6 → unknown puzzle entrypoint #2
  4. Selector 0xb8da5144 decodes calldata as a dynamic array, then calls func_0079 that:
    • Requires provided chunk count to equal storage[0x00] (length).
    • For each chunk, concatenates it with the owner address from storage[0x01] and hashes the bytes.
    • Requires each hash to match the array stored at keccak256(0x00) + i.

So the storage already contains all expected hashes—we just need to recover the cleartext chunks.


>2. Dumping Contract Storage

I wrote a tiny helper to read the slots we care about directly from the RPC. It uses eth_getStorageAt to retrieve:

  • Slot 0: length (expected to be 8)
  • Slot 1: packed owner address
  • Slots keccak256(0)+7: each chunk hash

Tooling: dump_storage.py

python
#!/usr/bin/env python3
import json
import math
import requests
from Crypto.Hash import keccak

RPC_URL = "<https://ethereum-sepolia-rpc.publicnode.com>"
CONTRACT = "0x1d7E03675b15a6602A14Ff6321A2cc2ea16CF53C"

def rpc(method, params):
    payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
    res = requests.post(RPC_URL, json=payload, timeout=30)
    res.raise_for_status()
    data = res.json()
    if "error" in data:
        raise RuntimeError(data["error"])
    return data["result"]

slot0 = int(rpc("eth_getStorageAt", [CONTRACT, hex(0), "latest"]), 16)
slot1 = rpc("eth_getStorageAt", [CONTRACT, hex(1), "latest"])
owner = slot1[-40:]
base_slot = keccak.new(digest_bits=256)
base_slot.update((0).to_bytes(32, "big"))
start = int.from_bytes(base_slot.digest(), "big")
print(f"length={slot0}")
print(f"owner=0x{owner}")
print(f"base slot=0x{start:064x}")
for idx in range(slot0):
    raw = rpc("eth_getStorageAt", [CONTRACT, hex(start + idx), "latest"])
    print(f"chunk[{idx}] = {raw}")

Output

length=8 owner=0x1597126b98a9560ca91ad4b926d0def7e2c45603 chunk[0] = 0xf59964cd0c25442208c8d0135bf938cf10dee456234ac55bccafac25e7f16234 chunk[1] = 0xa12f9f56c9d0067235de6a2fd821977bacc4d5ed6a9d9f7e38d643143f855688 chunk[2] = 0x3486d083d2655b16f836dcf07114c4a738727c9481b620cdf2db59cd5acfe372 chunk[3] = 0x2dfb14ffa4d2fe750d6e28014c3013793b22e122190a335a308f0d330143da3d chunk[4] = 0xd62d22652789151588d2d49bcd0d20a41e2ba09f319f6cf84bc712ea45a215ef chunk[5] = 0x6cf18571f33a226462303a6ae09be5de3c725b724bf623b5691dcb60651ee136 chunk[6] = 0x2b86ca86c8cfc8aa383afc78aa91ab265b174071d300c720e178264d2f647a42 chunk[7] = 0xe9d5b7877c45245ca46dc5975dc6b577baa951b05f59a8e7b87468bfad4a956d

Great—we now know the owner address and the eight hash targets.


>3. Understanding the Hash Constraint

From func_0079 (decompiled):

  • Each calldata chunk is ABI-decoded as bytes32.
  • The function copies the chunk, concatenates the owner address (storage[1]), and hashes using keccak256.
  • It compares the result to the stored hash at index i.

That means each cleartext chunk is shorter than 32 bytes (likely ASCII) and padded with zeros. The hash is effectively:

target[i] = keccak256(chunk_i || owner_address)

Because the expected strings look flag-like, brute forcing short lengths is feasible.


>4. Brute-Forcing the Chunks

Tooling: solve.py

python
from __future__ import annotations

import itertools
from typing import Dict, List

from Crypto.Hash import keccak

OWNER = bytes.fromhex("1597126b98a9560ca91ad4b926d0def7e2c45603")
HASHES: List[str] = [
    "f59964cd0c25442208c8d0135bf938cf10dee456234ac55bccafac25e7f16234",
    "a12f9f56c9d0067235de6a2fd821977bacc4d5ed6a9d9f7e38d643143f855688",
    "3486d083d2655b16f836dcf07114c4a738727c9481b620cdf2db59cd5acfe372",
    "2dfb14ffa4d2fe750d6e28014c3013793b22e122190a335a308f0d330143da3d",
    "d62d22652789151588d2d49bcd0d20a41e2ba09f319f6cf84bc712ea45a215ef",
    "6cf18571f33a226462303a6ae09be5de3c725b724bf623b5691dcb60651ee136",
    "2b86ca86c8cfc8aa383afc78aa91ab265b174071d300c720e178264d2f647a42",
    "e9d5b7877c45245ca46dc5975dc6b577baa951b05f59a8e7b87468bfad4a956d",
]
TARGET_MAP: Dict[bytes, int] = {bytes.fromhex(h): idx for idx, h in enumerate(HASHES)}
ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_-!@#$%^&*()[]<>?/\\\\|.,'\\"+="

def keccak_chunk(candidate: str) -> bytes:
    chunk_bytes = candidate.encode()
    padded = chunk_bytes + b"\\x00" * (32 - len(chunk_bytes))
    sponge = keccak.new(digest_bits=256)
    sponge.update(padded + OWNER)
    return sponge.digest()

def search(max_len: int = 4) -> Dict[int, str]:
    found: Dict[int, str] = {}
    for length in range(1, max_len + 1):
        for combo in itertools.product(ALPHABET, repeat=length):
            candidate = "".join(combo)
            digest = keccak_chunk(candidate)
            index = TARGET_MAP.get(digest)
            if index is None or index in found:
                continue
            found[index] = candidate
            print(f"matched chunk #{index} -> {candidate!r}")
            if len(found) == len(HASHES):
                return found
    return found

if __name__ == "__main__":
    matches = search()
    for idx in range(len(HASHES)):
        value = matches.get(idx, "<missing>")
        print(f"{idx}: {value}")

Running the Solver

$ . .venv/bin/activate && python solve.py matched chunk #0 -> 'nite' matched chunk #5 -> 'n07_' matched chunk #6 -> 'pRiv' matched chunk #2 -> 'v8_v' matched chunk #4 -> '4r3_' matched chunk #3 -> '4r$_' matched chunk #7 -> '473}' matched chunk #1 -> '{Pr1' 0: nite 1: {Pr1 2: v8_v 3: 4r$_ 4: 4r3_ 5: n07_ 6: pRiv 7: 473}

All hashes resolved with strings of length ≤4 drawn from the printable alphabet defined above.


>5. Reconstructing the Flag

Concatenate the eight chunks in order (0 → 7):

nite {Pr1 v8_v 4r$_ 4r3_ n07_ pRiv 473}

Removing spaces (only for readability above) yields the final flag:

nite{Pr1v8_v4r$_4r3_n07_pRiv473}

>6. Optional: On-Chain Verification

If desired, craft calldata for selector 0xb8da5144 with the eight ABI-encoded bytes32 chunks you recovered. Invoking the function on Sepolia (with eth_call) should return true, confirming the solution without spending gas.