Skip to main content

Tap into Hash picoCTF 2025 Solution

Reverse a custom cipher that combines hashing and XOR operations to recover the encrypted flag.

Published: April 2, 2025Updated: August 25, 2026

Description

The provided blockchain script hashes the key with SHA-256 and XORs every 16-byte block with that digest. With the key and ciphertext in hand, decrypting is as simple as repeating the XOR.

Download both block_chain.py and enc_flag from the challenge files.

Read the script to see how xor_bytes and the block loop work; the same routine can be run in reverse.

The key is not in block_chain.py's source; block_chain.py generates a random key at runtime and prints it. Open enc_flag in a text editor (or run head -1 enc_flag): the first line reads Key: b'\x...'. Copy that byte string verbatim into your solve script.

Confirm enc_flag is a flat binary blob: file enc_flag should print something like data (not ASCII text). If file reports it as text, the bytes are hex-encoded and you'll need bytes.fromhex(Path('enc_flag').read_text().strip()) instead of read_bytes().

Confirm enc_flag length is a multiple of 16 with wc -c enc_flag so block alignment is fine.

bash
wget https://challenge-files.picoctf.net/c_verbal_sleep/ba64ef56074be4d9f1b047eb451185d84de7c490e264b9ea6e645bd9b0956c01/block_chain.py
bash
wget https://challenge-files.picoctf.net/c_verbal_sleep/ba64ef56074be4d9f1b047eb451185d84de7c490e264b9ea6e645bd9b0956c01/enc_flag
bash
file enc_flag
bash
wc -c enc_flag

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1Reimplement xor_bytes
    Observation
    The source includes an xor_bytes helper that XORs paired bytes from two sequences. XOR is its own inverse, so that same function decrypts without a single change.
    Copy the helper function from the source: it simply XORs each ciphertext byte with the corresponding key_hash byte. You'll use the same function to decrypt.
    Learn more

    XOR (exclusive OR) is the foundation of most stream cipher constructions. Its critical property is self-inverse: A XOR B XOR B = A. This means the exact same operation encrypts and decrypts. XOR the plaintext with the keystream to get ciphertext; XOR the ciphertext with the same keystream to recover the plaintext. No separate "decrypt" function is needed.

    The xor_bytes helper operates byte-by-byte using Python's zip() to pair corresponding bytes from two sequences. This is a clean, Pythonic pattern for byte-level operations. The equivalent in a single line is: bytes(a ^ b for a, b in zip(ciphertext, key)). When the keystream is shorter than the data, zip silently truncates, a potential bug in production code that should use itertools.cycle to repeat the key if needed.

    The challenge name "tap into hash" hints at using a hash function as a key derivation step. This is conceptually similar to how HMAC-based key derivation (HKDF) works in real protocols; a hash function expands a master secret into keying material. Here, SHA-256 of the key produces exactly 32 bytes of uniform-looking output, which is then used as the XOR mask repeated across blocks. See stream ciphers in CTFs for why this construction is broken.

  2. Step 2Derive the keystream
    Observation
    The script runs the key through SHA-256 before using it as the mask, and the first line of enc_flag holds the raw key bytes. Hash those and you have the exact keystream.
    Hash the provided key with SHA-256 (key_hash = hashlib.sha256(key).digest()) so you have the exact keystream the challenge used.
    Learn more

    Using a hash of the key rather than the raw key has two purposes: first, it produces a fixed-length output (32 bytes for SHA-256) regardless of key length, making the XOR mask size predictable. Second, it applies a one-way transformation; someone who sees the keystream cannot directly recover the key. This is a primitive form of key derivation.

    However, this construction is not a secure cipher. The fundamental flaw is keystream reuse: every 16-byte block is XORed with the same 32-byte key hash (reusing the first 16 bytes for each block). If an attacker knows any 16 bytes of plaintext, they can recover the keystream for that position and decrypt any other block at the same offset. This is the same weakness that broke the Lorenz cipher in WWII and the RC4 stream cipher in WPA-TKIP.

    Secure stream cipher design uses a unique keystream segment for each block, typically by combining the key with a nonce (number used once) that changes per message. Modern authenticated encryption like AES-GCM or ChaCha20-Poly1305 handles both encryption and integrity verification, preventing both decryption without the key and undetected message tampering. The AES for CTF guide covers GCM in depth.

  3. Step 3Loop through the blocks
    Observation
    Encryption splits the file into 16-byte chunks and XORs each against the key hash independently. Run the same loop with the recovered keystream and append the results.
    Iterate over the ciphertext in 16-byte chunks, XOR each block with key_hash, and append the result. Printing the combined buffer reveals the flag embedded among hex strings; grep it for convenience.
    python
    python3 - <<'PY'
    import hashlib
    from pathlib import Path
    
    def xor_bytes(a, b):
        return bytes(x ^ y for x, y in zip(a, b))
    
    encrypted = Path('enc_flag').read_bytes()
    # Paste the exact key bytes from the FIRST LINE of enc_flag (the 'Key: b"..."' line)
    key = b"\x8b\x9a\x00G\xfe\xb3\xf3\x93\xdb\xa8yT\xfe\x15\x87a\xf4\xdf\x00\x8d\xee\xab\xd9\t^|\x04(%\x81\x9e\xf8"
    block_size = 16
    key_hash = hashlib.sha256(key).digest()
    flag = b''
    for i in range(0, len(encrypted), block_size):
        block = encrypted[i:i+block_size]
        flag += xor_bytes(block, key_hash)
    print(flag.decode(errors='ignore'))
    PY
    bash
    # Or save the snippet to solve.py and pipe through grep:
    # python3 solve.py | grep -oE 'picoCTF\{[^}]+\}'

    Expected output

    picoCTF{block_3SRhViRbT1qcX_XUjM0r49cH_qCzmJZzBK_6064...}
    What didn't work first

    Tried: XOR each ciphertext block directly against the raw key bytes instead of the SHA-256 digest of the key.

    The output is garbage: no readable ASCII, no flag prefix. The script hashes the key before using it as the mask, so the keystream is the 32-byte digest and not the key. Skip the hash and every block gets the wrong bytes, with no decode error to tell you why.

    Tried: Load enc_flag with Path('enc_flag').read_text() and call bytes.fromhex() on it, treating the file as a hex-encoded ciphertext.

    bytes.fromhex raises a value error, because enc_flag is a raw binary blob rather than a hex string; the setup step already showed file reporting it as data. read_bytes loads it directly. The fromhex path only applies to files that really are hex-encoded.

    Learn more

    Iterating in fixed-size chunks is a fundamental pattern in block-oriented cryptography. Python's range(0, len(data), block_size) with slice notation data[i:i+block_size] is the idiomatic way to split data into blocks. The last chunk may be shorter than block_size if the data length isn't a multiple of the block size; this is handled automatically by zip's truncation behavior, though production code should handle padding explicitly.

    The name "block_chain.py" is a pun; it chains together the XOR blocks but has nothing to do with blockchain technology (distributed ledger systems). This kind of thematic naming is common in CTF challenges to add flavor without complicating the actual puzzle. The enc_flag file being a flat binary rather than an encoded format means Path.read_bytes() is the correct way to load it; no base64 or hex decoding needed before the XOR step.

    The decode(errors='ignore') argument tells Python to silently skip bytes that aren't valid UTF-8. You need it here because the encryption padded the flag's last block with arbitrary bytes to reach a 16-byte boundary; decrypting that block yields a few non-UTF-8 trailers after the closing }. Without errors='ignore', .decode() would raise UnicodeDecodeError on those trailers and you'd never see the readable flag at all. The subsequent grep -oE 'picoCTF\{[^}]+\}' step extracts just the flag text from the mixed output. In a production script, you'd use Python's re.search to do the same extraction programmatically.

Interactive tools
  • XOR CipherXOR-decrypt hex or text ciphertext with a known key, or brute-force the single-byte key automatically.
  • Cipher Identifier & Auto-DecoderPaste any ciphertext and the tool auto-runs every common decoder (base64, hex, Morse, ROT, Atbash, Bacon, binary, decimal, URL) and ranks the results by English-likeness.
  • Frequency AnalysisAnalyze letter frequencies in a substitution cipher and interactively build the decryption mapping with auto-filled guesses.
Alternate Solution

After deriving the SHA-256 key hash, use the XOR Cipher tool on this site to XOR each 16-byte block of the ciphertext against the key hash manually - paste the hex bytes, enter the hash as the key, and the plaintext appears without writing any Python.

Flag

Reveal flag

picoCTF{block_3SRhViRbT1qcX_XUjM0r49cH_qCzmJZzBK_6064...}

Padding bytes leave stray characters, so piping the output through `grep -o 'picoCTF{...}'` is the quickest way to isolate the flag.

Key takeaway

XOR encryption is only as strong as its keystream is unique and secret. Reuse a segment across blocks and any known plaintext at that position hands over the keystream bytes, which decrypt every other block at the same offset without the key. Keystream reuse broke the Lorenz cipher and RC4 in WPA-TKIP, and it breaks any homemade cipher that derives a fixed mask from a hash and cycles it. AES-GCM and ChaCha20-Poly1305 fold a nonce into the cipher state to guarantee a fresh keystream per message.

Related reading

Useful tools for Reverse Engineering

Where to go next