Tap into Hash picoCTF 2025 Solution

Published: April 2, 2025

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 1
    Reimplement xor_bytes
    Observation
    I noticed the challenge provides the full source of block_chain.py, which includes a xor_bytes helper that XORs paired bytes from two sequences, and since XOR is self-inverse, the exact same function can decrypt the ciphertext without any modification.
    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 2
    Derive the keystream
    Observation
    I noticed block_chain.py passes the key through hashlib.sha256(key).digest() before using it as the XOR mask, and the first line of enc_flag prints the raw key bytes, so hashing that key with SHA-256 reproduces the exact keystream the cipher used.
    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 3
    Loop through the blocks
    Observation
    I noticed the encryption script splits enc_flag into 16-byte chunks and XORs each block against the key hash independently, so running the same loop with the recovered keystream and appending results reconstructs the plaintext containing the flag.
    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 pure garbage - no recognizable ASCII, no flag prefix. The challenge script hashes the key with hashlib.sha256(key).digest() before using it as the XOR mask, so the keystream is the 32-byte hash output, not the key itself. Skipping the sha256 step means every block is XORed with the wrong bytes, producing random-looking output with no UnicodeDecodeError to warn you something is off.

    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 ValueError: non-hexadecimal number found in final chunk because enc_flag is a raw binary blob, not a hex string. The setup step confirms this with 'file enc_flag' printing 'data'. Using read_bytes() loads the correct raw bytes directly; the fromhex path is only needed when the file is explicitly described as hex-encoded, which this one is not.

    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.
  • Hash IdentifierIdentify unknown hash types by length and prefix. Covers MD5, SHA-1, SHA-256, SHA-512, bcrypt, NTLM, and more.
  • Checksum CalculatorCompute CRC32, SHA-1, SHA-256, SHA-384, and SHA-512 hashes for text or uploaded files. Verify against known hashes.
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-based encryption is only as strong as the uniqueness and secrecy of its keystream. When the same keystream segment is reused across multiple plaintext blocks, any known plaintext at that position directly reveals the keystream bytes, allowing an attacker to decrypt all other blocks at the same offset without knowing the original key. This keystream-reuse weakness historically broke the Lorenz cipher, RC4 in WPA-TKIP, and any home-built cipher that derives a fixed mask from a hash then cycles it. Secure constructions like AES-GCM and ChaCha20-Poly1305 guarantee a unique keystream per message by incorporating a nonce into the cipher state.

Related reading

Want more picoCTF 2025 writeups?

Useful tools for Reverse Engineering

What to try next