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.

Open block_chain.py in your editor and search for key = . There's exactly one line; copy the byte string (the b"..." literal) 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
  1. Step 1Reimplement xor_bytes
    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
    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
    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 bytes from block_chain.py (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\{[^}]+\}'
    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.

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

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.

Want more picoCTF 2025 writeups?

Useful tools for Reverse Engineering

Related reading

What to try next